cloud creation

This commit is contained in:
Первов Артем
2026-06-17 09:36:58 +03:00
commit 123bd97018
46 changed files with 12285 additions and 0 deletions

21
src/app.module.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CurrentModule } from './current/current.module';
import { DbModule } from './db/db.module';
import { EdgeModule } from './edge/edge.module';
import { HealthController } from './health.controller';
import { HistoryModule } from './history/history.module';
import { IngestModule } from './ingest/ingest.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DbModule,
IngestModule,
EdgeModule,
CurrentModule,
HistoryModule,
],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,25 @@
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));
}

View File

@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
@Injectable()
export class IngestApiKeyGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const expectedKey = this.config.get<string>('INGEST_API_KEY');
if (!expectedKey) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const actualKey = request.header('x-api-key');
if (actualKey !== expectedKey) {
throw new UnauthorizedException('Invalid ingest API key.');
}
return true;
}
}

View File

@@ -0,0 +1,11 @@
import { BadRequestException } from '@nestjs/common';
// Единая нормализация обязательных строк: trim + понятная 400-ошибка для API.
export function normalizeRequiredText(value: string, fieldName: string): string {
const normalized = value.trim();
if (!normalized) {
throw new BadRequestException(`${fieldName} is required.`);
}
return normalized;
}

13
src/common/query-list.ts Normal file
View File

@@ -0,0 +1,13 @@
// Поддерживает both styles: ?tags=a,b и ?tags=a&tags=b; дубликаты убираются.
export function parseCommaSeparatedList(value: unknown): string[] | undefined {
if (value == null || value === '') {
return undefined;
}
const rawItems = Array.isArray(value)
? value.flatMap((item) => String(item).split(','))
: String(value).split(',');
const items = rawItems.map((item) => item.trim()).filter(Boolean);
return items.length ? Array.from(new Set(items)) : undefined;
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Observable, Subject, filter, map } from 'rxjs';
export type CurrentEventItem = {
edge: string;
tag: string;
value: number;
time: Date;
updatedAt: Date;
};
export type CurrentEvent = {
edge: string;
items: CurrentEventItem[];
};
@Injectable()
export class CurrentEventsService {
private readonly events = new Subject<CurrentEvent>();
// Публикует latest-изменения после успешной записи ingest; подписчики получают только свой edge/tags.
publish(event: CurrentEvent): void {
if (event.items.length) {
this.events.next(event);
}
}
stream(edge: string, tags?: string[]): Observable<{ data: CurrentEvent }> {
const tagSet = tags?.length ? new Set(tags) : undefined;
return this.events.asObservable().pipe(
filter((event) => event.edge === edge),
map((event) => ({
...event,
items: tagSet ? event.items.filter((item) => tagSet.has(item.tag)) : event.items,
})),
filter((event) => event.items.length > 0),
map((event) => ({ data: event })),
);
}
}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Header, Query, Sse } from '@nestjs/common';
import { Observable } from 'rxjs';
import { CurrentEventsService } from './current-events.service';
import { CurrentService } from './current.service';
import { GetCurrentDto } from './dto/get-current.dto';
@Controller('current')
export class CurrentController {
constructor(
private readonly current: CurrentService,
private readonly currentEvents: CurrentEventsService,
) {}
@Get()
@Header('Cache-Control', 'no-store')
@Header('Pragma', 'no-cache')
@Header('Expires', '0')
findByEdge(@Query() query: GetCurrentDto) {
return this.current.findByEdge(query);
}
@Sse('events')
@Header('Cache-Control', 'no-store')
events(@Query() query: GetCurrentDto): Observable<MessageEvent> {
return this.currentEvents.stream(query.edge, query.tags) as unknown as Observable<MessageEvent>;
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CurrentController } from './current.controller';
import { CurrentEventsService } from './current-events.service';
import { CurrentService } from './current.service';
@Module({
controllers: [CurrentController],
providers: [CurrentService, CurrentEventsService],
exports: [CurrentEventsService],
})
export class CurrentModule {}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { normalizeRequiredText } from '../common/normalize-text';
import { DbService } from '../db/db.service';
import { GetCurrentDto } from './dto/get-current.dto';
type CurrentRow = {
edge_id: string;
tag_id: string;
value: number;
time: Date;
updated_at: Date;
};
type CurrentItem = {
edge: string;
tag: string;
value: number;
time: Date;
updatedAt: Date;
};
type CurrentResponse = {
edge: string;
items: CurrentItem[];
};
@Injectable()
export class CurrentService {
constructor(private readonly db: DbService) {}
// Возвращает latest-срез по edge; optional tags ограничивает ответ нужными сериями.
async findByEdge(query: GetCurrentDto): Promise<CurrentResponse> {
const edge = normalizeRequiredText(query.edge, 'edge');
const tags = query.tags ?? null;
const result = await this.db.query<CurrentRow>(
`
SELECT edge_id, tag_id, value, time, updated_at
FROM current_values
WHERE edge_id = $1
AND ($2::text[] IS NULL OR tag_id = ANY($2::text[]))
ORDER BY tag_id ASC
`,
[edge, tags],
);
return {
edge,
items: result.rows.map((row) => ({
edge: row.edge_id,
tag: row.tag_id,
value: row.value,
time: row.time,
updatedAt: row.updated_at,
})),
};
}
}

View File

@@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { parseCommaSeparatedList } from '../../common/query-list';
export class GetCurrentDto {
@IsNotEmpty()
@IsString()
edge!: string;
@IsOptional()
@Transform(({ value }) => parseCommaSeparatedList(value))
@IsArray()
@IsString({ each: true })
tags?: string[];
}

9
src/db/db.module.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { DbService } from './db.service';
@Global()
@Module({
providers: [DbService],
exports: [DbService],
})
export class DbModule {}

83
src/db/db.service.ts Normal file
View File

@@ -0,0 +1,83 @@
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';
type DatabaseHealth = {
now: Date;
timescaledb_installed: boolean;
timescaledb_version: string | null;
};
@Injectable()
export class DbService implements OnModuleInit, OnModuleDestroy {
private pool!: Pool;
constructor(private readonly config: ConfigService) {}
// Инициализируем пул один раз на модуль и сразу проверяем доступность БД.
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),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
statement_timeout: 60_000,
query_timeout: 60_000,
application_name: 'drill-cloud-v2',
});
await this.health();
}
async onModuleDestroy(): Promise<void> {
await this.pool?.end();
}
query<T extends QueryResultRow = QueryResultRow>(
text: string,
values: readonly unknown[] = [],
): Promise<QueryResult<T>> {
return this.pool.query<T>(text, [...values]);
}
// Общая транзакционная обертка: caller описывает операции, сервис отвечает за commit/rollback.
async withTransaction<T>(handler: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await handler(client);
await client.query('COMMIT');
return result;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch {
// Preserve the original transaction error.
}
throw error;
} finally {
client.release();
}
}
// Легкий probe для /health: проверяет связь с БД и установленный TimescaleDB.
async health(): Promise<DatabaseHealth> {
const result = await this.query<DatabaseHealth>(`
SELECT
now(),
ext.extversion IS NOT NULL AS timescaledb_installed,
ext.extversion AS timescaledb_version
FROM (SELECT 1) AS probe
LEFT JOIN pg_extension AS ext
ON ext.extname = 'timescaledb'
`);
return result.rows[0];
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Header } from '@nestjs/common';
import { EdgeService } from './edge.service';
@Controller('edge')
export class EdgeController {
constructor(private readonly edge: EdgeService) {}
@Get()
@Header('Cache-Control', 'no-store')
@Header('Pragma', 'no-cache')
@Header('Expires', '0')
findAll() {
return this.edge.findAll();
}
}

9
src/edge/edge.module.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EdgeController } from './edge.controller';
import { EdgeService } from './edge.service';
@Module({
controllers: [EdgeController],
providers: [EdgeService],
})
export class EdgeModule {}

71
src/edge/edge.service.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { DbService } from '../db/db.service';
type EdgeRow = {
id: string;
name: string;
tag_count: number;
current_tag_count: number;
live_tag_count: number;
last_data_at: Date | null;
created_at: Date;
updated_at: Date;
};
type EdgeItem = {
id: string;
name: string;
tagCount: number;
currentTagCount: number;
liveTagCount: number;
lastDataAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type EdgeResponse = {
items: EdgeItem[];
};
@Injectable()
export class EdgeService {
constructor(private readonly db: DbService) {}
// Возвращает каталог буровых с легкой сводкой по свежести данных для dashboard UI.
async findAll(): Promise<EdgeResponse> {
const result = await this.db.query<EdgeRow>(`
SELECT
e.id,
e.name,
count(DISTINCT et.tag_id)::integer AS tag_count,
count(DISTINCT cv.tag_id)::integer AS current_tag_count,
count(DISTINCT cv.tag_id) FILTER (
WHERE cv.time >= now() - INTERVAL '30 seconds'
)::integer AS live_tag_count,
max(cv.time) AS last_data_at,
e.created_at,
e.updated_at
FROM edge AS e
LEFT JOIN edge_tag AS et
ON et.edge_id = e.id
LEFT JOIN current_values AS cv
ON cv.edge_id = e.id
AND cv.tag_id = et.tag_id
GROUP BY e.id, e.name, e.created_at, e.updated_at
ORDER BY e.name ASC, e.id ASC
`);
return {
items: result.rows.map((row) => ({
id: row.id,
name: row.name,
tagCount: row.tag_count,
currentTagCount: row.current_tag_count,
liveTagCount: row.live_tag_count,
lastDataAt: row.last_data_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
};
}
}

16
src/health.controller.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import { DbService } from './db/db.service';
@Controller()
export class HealthController {
constructor(private readonly db: DbService) {}
@Get('health')
async health() {
const database = await this.db.health();
return {
status: 'ok',
database,
};
}
}

View File

@@ -0,0 +1,50 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsDateString,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { parseCommaSeparatedList } from '../../common/query-list';
const VALUE_MODES = ['avg', 'last', 'first', 'min', 'max'] as const;
export type ValueMode = (typeof VALUE_MODES)[number];
export class GetHistoryDto {
@IsNotEmpty()
@IsString()
edge!: string;
@IsOptional()
@Transform(({ value }) => parseCommaSeparatedList(value))
@IsArray()
@ArrayMaxSize(500)
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsDateString()
from?: string;
@IsOptional()
@IsDateString()
to?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(100)
@Max(2000)
targetPoints?: number;
@IsOptional()
@IsString()
@IsIn(VALUE_MODES)
valueMode?: ValueMode;
}

View File

@@ -0,0 +1,43 @@
import { HistorySource } from './history.types';
export const RAW_HISTORY_SOURCE: HistorySource = {
name: 'raw',
table: 'history_points',
timeColumn: 'time',
intervalSeconds: 0,
};
export const AGGREGATE_HISTORY_SOURCES: HistorySource[] = [
{ name: '1m', table: 'history_1m', timeColumn: 'bucket', intervalSeconds: 60 },
{ name: '5m', table: 'history_5m', timeColumn: 'bucket', intervalSeconds: 300 },
{ name: '1h', table: 'history_1h', timeColumn: 'bucket', intervalSeconds: 3600 },
{ name: '1d', table: 'history_1d', timeColumn: 'bucket', intervalSeconds: 86400 },
];
// Подбирает самый дешевый слой данных под диапазон и целевое число точек на графике.
export function chooseHistorySource(
from: Date,
to: Date,
targetPoints: number,
): HistorySource {
const durationSeconds = Math.max((to.getTime() - from.getTime()) / 1000, 1);
const desiredSeconds = durationSeconds / targetPoints;
if (desiredSeconds < 60) {
return RAW_HISTORY_SOURCE;
}
if (desiredSeconds < 300) {
return AGGREGATE_HISTORY_SOURCES[0];
}
if (desiredSeconds < 3600) {
return AGGREGATE_HISTORY_SOURCES[1];
}
if (desiredSeconds < 86400) {
return AGGREGATE_HISTORY_SOURCES[2];
}
return AGGREGATE_HISTORY_SOURCES[3];
}

View File

@@ -0,0 +1,16 @@
import { Controller, Get, Header, Query } from '@nestjs/common';
import { GetHistoryDto } from './dto/get-history.dto';
import { HistoryService } from './history.service';
@Controller('history')
export class HistoryController {
constructor(private readonly history: HistoryService) {}
@Get()
@Header('Cache-Control', 'no-store')
@Header('Pragma', 'no-cache')
@Header('Expires', '0')
findSeries(@Query() query: GetHistoryDto) {
return this.history.findSeries(query);
}
}

View File

@@ -0,0 +1,61 @@
import { ValueMode } from './dto/get-history.dto';
import { HistoryResponse, HistoryResponseSource, HistoryRow, HistorySeries } from './history.types';
type HistoryResponseOptions = {
edge: string;
source: HistoryResponseSource;
targetPoints: number;
rows?: HistoryRow[];
from?: Date;
to?: Date;
resolutionSeconds?: number | null;
valueMode?: ValueMode | 'raw';
};
// Собирает стабильный API response независимо от выбранного SQL-источника.
export function createHistoryResponse(options: HistoryResponseOptions): HistoryResponse {
return {
edge: options.edge,
source: options.source,
targetPoints: options.targetPoints,
series: mapHistoryRowsToSeries(options.rows ?? []),
from: options.from,
to: options.to,
resolutionSeconds: options.resolutionSeconds,
valueMode: options.valueMode,
};
}
// Группирует плоские строки БД в серии, удобные для frontend-графиков.
function mapHistoryRowsToSeries(rows: HistoryRow[]): HistorySeries[] {
const seriesMap = new Map<string, HistorySeries>();
for (const row of rows) {
const key = `${row.edge_id}\u0000${row.tag_id}`;
const series = seriesMap.get(key) ?? {
edge: row.edge_id,
tag: row.tag_id,
points: [],
};
const point = {
t: row.time.getTime(),
v: row.value,
...(row.point_count !== undefined
? {
first: row.first_value,
min: row.min_value,
max: row.max_value,
avg: row.avg_value,
last: row.last_value,
count: row.point_count,
}
: {}),
};
series.points.push(point);
seriesMap.set(key, series);
}
return Array.from(seriesMap.values());
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HistoryController } from './history.controller';
import { HistoryRepository } from './history.repository';
import { HistoryService } from './history.service';
@Module({
controllers: [HistoryController],
providers: [HistoryRepository, HistoryService],
})
export class HistoryModule {}

View File

@@ -0,0 +1,186 @@
import { Injectable } from '@nestjs/common';
import { DbService } from '../db/db.service';
import { ValueMode } from './dto/get-history.dto';
import { HistoryRow, HistorySource } from './history.types';
const AGGREGATE_VALUE_COLUMNS: Record<ValueMode, string> = {
avg: 'avg_value',
last: 'last_value',
first: 'first_value',
min: 'min_value',
max: 'max_value',
};
const DOWNSAMPLE_PREDICATE = `
total <= $5
OR mod(rn - 1, greatest(ceil(total::numeric / $5)::integer, 1)) = 0
OR rn = total
`;
@Injectable()
export class HistoryRepository {
constructor(private readonly db: DbService) {}
// Берет последние N raw-точек по каждому tag без диапазона времени.
async findLatest(
edge: string,
tags: string[],
targetPoints: number,
): Promise<HistoryRow[]> {
const result = await this.db.query<HistoryRow>(
`
SELECT
$1::text AS edge_id,
latest.tag_id,
latest.time,
latest.value
FROM unnest($2::text[]) AS requested(tag_id)
CROSS JOIN LATERAL (
SELECT tag_id, time, value
FROM history_points
WHERE edge_id = $1
AND tag_id = requested.tag_id
ORDER BY time DESC
LIMIT $3
) AS latest
ORDER BY latest.tag_id ASC, latest.time ASC
`,
[edge, tags, targetPoints],
);
return result.rows;
}
// Читает raw-диапазон и прореживает его до targetPoints на каждую серию.
async findRawRange(
edge: string,
tags: string[],
from: Date,
to: Date,
targetPoints: number,
): Promise<HistoryRow[]> {
const result = await this.db.query<HistoryRow>(
`
WITH filtered AS (
SELECT edge_id, tag_id, time, value
FROM history_points
WHERE edge_id = $1
AND tag_id = ANY($2::text[])
AND time >= $3
AND time <= $4
),
ranked AS (
SELECT
edge_id,
tag_id,
time,
value,
row_number() OVER (PARTITION BY tag_id ORDER BY time ASC) AS rn,
count(*) OVER (PARTITION BY tag_id) AS total
FROM filtered
)
SELECT edge_id, tag_id, time, value
FROM ranked
WHERE ${DOWNSAMPLE_PREDICATE}
ORDER BY tag_id ASC, time ASC
`,
[edge, tags, from, to, targetPoints],
);
return result.rows;
}
// Читает continuous aggregate; valueMode выбирает колонку avg/last/min/max/first.
async findAggregateRange(
edge: string,
tags: string[],
from: Date,
to: Date,
targetPoints: number,
source: HistorySource,
valueMode: ValueMode,
): Promise<HistoryRow[]> {
const valueColumn = AGGREGATE_VALUE_COLUMNS[valueMode];
// Имена таблиц/колонок берутся только из whitelist-констант, не из пользовательского ввода.
const result = await this.db.query<HistoryRow>(
`
WITH filtered AS (
SELECT
edge_id,
tag_id,
${source.timeColumn} AS time,
first_value,
min_value,
max_value,
avg_value,
last_value,
point_count,
${valueColumn} AS value
FROM ${source.table}
WHERE edge_id = $1
AND tag_id = ANY($2::text[])
AND ${source.timeColumn} >= $3
AND ${source.timeColumn} <= $4
),
ranked AS (
SELECT
*,
row_number() OVER (PARTITION BY tag_id ORDER BY time ASC) AS rn,
count(*) OVER (PARTITION BY tag_id) AS total
FROM filtered
)
SELECT
edge_id,
tag_id,
time,
value,
first_value,
min_value,
max_value,
avg_value,
last_value,
point_count
FROM ranked
WHERE ${DOWNSAMPLE_PREDICATE}
ORDER BY tag_id ASC, time ASC
`,
[edge, tags, from, to, targetPoints],
);
return result.rows;
}
// Если tags не переданы, используем каталог edge_tag; fallback нужен для сырых импортов без связей.
async resolveTags(edge: string, requestedTags?: string[]): Promise<string[]> {
if (requestedTags?.length) {
return requestedTags;
}
const linked = await this.db.query<{ tag_id: string }>(
`
SELECT tag_id
FROM edge_tag
WHERE edge_id = $1
ORDER BY tag_id ASC
`,
[edge],
);
if (linked.rows.length) {
return linked.rows.map((row) => row.tag_id);
}
const distinct = await this.db.query<{ tag_id: string }>(
`
SELECT DISTINCT tag_id
FROM history_points
WHERE edge_id = $1
ORDER BY tag_id ASC
`,
[edge],
);
return distinct.rows.map((row) => row.tag_id);
}
}

View File

@@ -0,0 +1,92 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getIntegerConfig } from '../common/config-number';
import { normalizeRequiredText } from '../common/normalize-text';
import { GetHistoryDto } from './dto/get-history.dto';
import { chooseHistorySource } from './history-source';
import { createHistoryResponse } from './history.mapper';
import { HistoryRepository } from './history.repository';
import { HistoryResponse } from './history.types';
@Injectable()
export class HistoryService {
constructor(
private readonly repository: HistoryRepository,
private readonly config: ConfigService,
) {}
// Use-case для графиков: выбирает latest/raw/aggregate и возвращает единый API-формат.
async findSeries(query: GetHistoryDto): Promise<HistoryResponse> {
const edge = normalizeRequiredText(query.edge, 'edge');
const targetPoints = this.getTargetPoints(query.targetPoints);
const tags = await this.repository.resolveTags(edge, query.tags);
if (!tags.length) {
return createHistoryResponse({
edge,
source: 'empty',
targetPoints,
});
}
if (!query.from && !query.to) {
const rows = await this.repository.findLatest(edge, tags, targetPoints);
return createHistoryResponse({
edge,
source: 'latest',
targetPoints,
rows,
});
}
const { from, to } = this.parseRange(query);
const source = chooseHistorySource(from, to, targetPoints);
const rows =
source.name === 'raw'
? await this.repository.findRawRange(edge, tags, from, to, targetPoints)
: await this.repository.findAggregateRange(
edge,
tags,
from,
to,
targetPoints,
source,
query.valueMode ?? 'avg',
);
return createHistoryResponse({
edge,
from,
to,
source: source.name,
resolutionSeconds: source.intervalSeconds || null,
targetPoints,
valueMode: source.name === 'raw' ? 'raw' : query.valueMode ?? 'avg',
rows,
});
}
// Частично заданный диапазон поддерживается: open-ended сторона получает безопасный default.
private parseRange(query: GetHistoryDto): { from: Date; to: Date } {
const from = query.from ? new Date(query.from) : new Date(0);
const to = query.to ? new Date(query.to) : new Date();
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid from/to date.');
}
if (from > to) {
throw new BadRequestException('from cannot be greater than to.');
}
return { from, to };
}
// Ограничивает плотность ответа, чтобы 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 value = requested ?? defaultValue;
return Math.min(Math.max(Math.floor(value), 100), maxValue);
}
}

View File

@@ -0,0 +1,53 @@
import { ValueMode } from './dto/get-history.dto';
export type HistorySourceName = 'raw' | '1m' | '5m' | '1h' | '1d';
export type HistorySource = {
name: HistorySourceName;
table: string;
timeColumn: string;
intervalSeconds: number;
};
export type HistoryRow = {
edge_id: string;
tag_id: string;
time: Date;
value: number;
first_value?: number;
min_value?: number;
max_value?: number;
avg_value?: number;
last_value?: number;
point_count?: number;
};
export type HistoryPoint = {
t: number;
v: number;
first?: number;
min?: number;
max?: number;
avg?: number;
last?: number;
count?: number;
};
export type HistorySeries = {
edge: string;
tag: string;
points: HistoryPoint[];
};
export type HistoryResponseSource = HistorySourceName | 'empty' | 'latest';
export type HistoryResponse = {
edge: string;
source: HistoryResponseSource;
targetPoints: number;
series: HistorySeries[];
from?: Date;
to?: Date;
resolutionSeconds?: number | null;
valueMode?: ValueMode | 'raw';
};

View File

@@ -0,0 +1,16 @@
import { Type } from 'class-transformer';
import { IsDateString, IsNumber, IsObject, IsOptional } from 'class-validator';
export class IngestEdgeValuesDto {
@IsOptional()
@IsDateString()
time?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
timestamp?: number;
@IsObject()
values!: Record<string, number>;
}

View File

@@ -0,0 +1,25 @@
import { Type } from 'class-transformer';
import { IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class IngestPointDto {
@IsNotEmpty()
@IsString()
edge!: string;
@IsNotEmpty()
@IsString()
tag!: string;
@IsOptional()
@IsDateString()
time?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
timestamp?: number;
@Type(() => Number)
@IsNumber()
value!: number;
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Param, ParseArrayPipe, Post, UseGuards } from '@nestjs/common';
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
import { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto';
import { IngestPointDto } from './dto/ingest-point.dto';
import { IngestService } from './ingest.service';
@Controller('ingest')
@UseGuards(IngestApiKeyGuard)
export class IngestController {
constructor(private readonly ingest: IngestService) {}
@Post()
ingestPoints(
@Body(new ParseArrayPipe({ items: IngestPointDto }))
points: IngestPointDto[],
) {
return this.ingest.ingestPoints(points);
}
@Post(':edge')
ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) {
const points = Object.entries(body.values).map(([tag, value]) => ({
edge,
tag,
value: Number(value),
time: body.time,
timestamp: body.timestamp,
}));
return this.ingest.ingestPoints(points);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
import { CurrentModule } from '../current/current.module';
import { IngestController } from './ingest.controller';
import { IngestService } from './ingest.service';
@Module({
imports: [CurrentModule],
controllers: [IngestController],
providers: [IngestService, IngestApiKeyGuard],
})
export class IngestModule {}

View File

@@ -0,0 +1,234 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getIntegerConfig } from '../common/config-number';
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;
tag: string;
value: number;
};
type IngestResult = {
processed: number;
edges?: number;
tags?: number;
};
type CurrentValueRow = {
edge_id: string;
tag_id: string;
value: number;
time: Date;
updated_at: Date;
};
@Injectable()
export class IngestService {
constructor(
private readonly db: DbService,
private readonly config: ConfigService,
private readonly currentEvents: CurrentEventsService,
) {}
// Основной ingest pipeline: нормализация, справочники, raw history и latest values в одной транзакции.
async ingestPoints(points: IngestPointDto[]): Promise<IngestResult> {
if (!points.length) {
return { processed: 0 };
}
this.assertBatchSize(points.length);
const normalized = points.map((point) => this.normalizePoint(point));
const edges = Array.from(new Set(normalized.map((point) => point.edge)));
const tags = Array.from(new Set(normalized.map((point) => point.tag)));
const edgeTagPairs = Array.from(
new Map(normalized.map((point) => [`${point.edge}\u0000${point.tag}`, point])).values(),
);
await this.db.withTransaction(async (client) => {
await client.query(
`
INSERT INTO edge (id, name)
SELECT edge_id, edge_id
FROM unnest($1::text[]) AS edge_id
ON CONFLICT (id) DO NOTHING
`,
[edges],
);
await client.query(
`
INSERT INTO tag (id, name)
SELECT tag_id, tag_id
FROM unnest($1::text[]) AS tag_id
ON CONFLICT (id) DO NOTHING
`,
[tags],
);
await client.query(
`
INSERT INTO edge_tag (edge_id, tag_id)
SELECT edge_id, tag_id
FROM unnest($1::text[], $2::text[]) AS pair(edge_id, tag_id)
ON CONFLICT (edge_id, tag_id) DO NOTHING
`,
[edgeTagPairs.map((point) => point.edge), edgeTagPairs.map((point) => point.tag)],
);
await client.query(
`
INSERT INTO history_points (time, edge_id, tag_id, value)
SELECT time, edge_id, tag_id, value
FROM unnest(
$1::timestamptz[],
$2::text[],
$3::text[],
$4::double precision[]
) AS point(time, edge_id, tag_id, value)
`,
[
normalized.map((point) => point.time),
normalized.map((point) => point.edge),
normalized.map((point) => point.tag),
normalized.map((point) => point.value),
],
);
await client.query(
`
INSERT INTO current_values (edge_id, tag_id, value, time, updated_at)
SELECT DISTINCT ON (edge_id, tag_id)
edge_id,
tag_id,
value,
time,
now()
FROM unnest(
$1::text[],
$2::text[],
$3::double precision[],
$4::timestamptz[]
) AS point(edge_id, tag_id, value, time)
ORDER BY edge_id, tag_id, time DESC
ON CONFLICT (edge_id, tag_id) DO UPDATE
SET
value = EXCLUDED.value,
time = EXCLUDED.time,
updated_at = now()
WHERE EXCLUDED.time >= current_values.time
`,
[
normalized.map((point) => point.edge),
normalized.map((point) => point.tag),
normalized.map((point) => point.value),
normalized.map((point) => point.time),
],
);
});
await this.publishCurrentChanges(edgeTagPairs);
return {
processed: normalized.length,
edges: edges.length,
tags: tags.length,
};
}
private async publishCurrentChanges(points: NormalizedPoint[]): Promise<void> {
if (!points.length) {
return;
}
const result = await this.db.query<CurrentValueRow>(
`
SELECT cv.edge_id, cv.tag_id, cv.value, cv.time, cv.updated_at
FROM current_values AS cv
JOIN (
SELECT DISTINCT edge_id, tag_id
FROM unnest($1::text[], $2::text[]) AS pair(edge_id, tag_id)
) AS changed
ON changed.edge_id = cv.edge_id
AND changed.tag_id = cv.tag_id
`,
[points.map((point) => point.edge), points.map((point) => point.tag)],
);
const byEdge = new Map<string, CurrentValueRow[]>();
result.rows.forEach((row) => {
const rows = byEdge.get(row.edge_id) ?? [];
rows.push(row);
byEdge.set(row.edge_id, rows);
});
byEdge.forEach((rows, edge) => {
this.currentEvents.publish({
edge,
items: rows.map((row) => ({
edge: row.edge_id,
tag: row.tag_id,
value: row.value,
time: row.time,
updatedAt: row.updated_at,
})),
});
});
}
// Ограничивает размер одного запроса, чтобы один producer не занял память и соединение надолго.
private assertBatchSize(size: number): void {
const maxBatchSize = Math.max(
getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE),
1,
);
if (size > maxBatchSize) {
throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`);
}
}
// Приводит входную точку к внутреннему формату и отсекает некорректные edge/tag/value/time.
private normalizePoint(point: IngestPointDto): NormalizedPoint {
const edge = normalizeRequiredText(point.edge, 'edge');
const tag = normalizeRequiredText(point.tag, 'tag');
const time = this.parsePointTime(point);
if (!Number.isFinite(point.value)) {
throw new BadRequestException('value must be a finite number.');
}
return {
edge,
tag,
value: point.value,
time,
};
}
// Принимаем ISO time или Unix milliseconds; отсутствие валидного времени считается ошибкой данных.
private parsePointTime(point: IngestPointDto): Date {
if (point.time) {
const date = new Date(point.time);
if (!Number.isNaN(date.getTime())) {
return date;
}
}
if (point.timestamp !== undefined) {
const date = new Date(point.timestamp);
if (!Number.isNaN(date.getTime())) {
return date;
}
}
throw new BadRequestException('Each point must contain valid time or timestamp.');
}
}

34
src/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import compression from 'compression';
import { AppModule } from './app.module';
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)
: true;
// CORS настраивается через env, чтобы один build работал локально и в окружениях деплоя.
app.enableCors({
origin: corsAllowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
});
// Whitelist защищает API от лишних полей в DTO и делает контракты запросов строгими.
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(process.env.PORT ?? 3100);
}
void bootstrap();

62
src/migrations/migrate.ts Normal file
View File

@@ -0,0 +1,62 @@
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Pool } from 'pg';
async function main() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required.');
}
const pool = new Pool({
connectionString,
application_name: 'drill-cloud-v2-migrate',
});
let lockAcquired = false;
try {
// Защищает от параллельного применения миграций несколькими инстансами API.
await pool.query('SELECT pg_advisory_lock(hashtext($1))', ['drill-cloud-v2:migrate']);
lockAcquired = true;
await pool.query(`
CREATE TABLE IF NOT EXISTS app_migrations (
id text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
`);
const migrationsDir = join(process.cwd(), 'migrations');
// Порядок определяется именем файла: 001_..., 002_..., и т.д.
const files = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort((left, right) => left.localeCompare(right));
for (const file of files) {
const applied = await pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM app_migrations WHERE id = $1)',
[file],
);
if (applied.rows[0]?.exists) {
console.log(`skip ${file}`);
continue;
}
const sql = await readFile(join(migrationsDir, file), 'utf8');
console.log(`apply ${file}`);
await pool.query(sql);
await pool.query('INSERT INTO app_migrations (id) VALUES ($1)', [file]);
}
} finally {
if (lockAcquired) {
await pool.query('SELECT pg_advisory_unlock(hashtext($1))', ['drill-cloud-v2:migrate']);
}
await pool.end();
}
}
void main().catch((error) => {
console.error(error);
process.exit(1);
});