Merge pull request 'Убран лишний код. Скорректированы docker и package файлы. Переработано взаимодействие с бд - упрощена логика.' (#1) from dev into main

Reviewed-on: #1
Reviewed-by: Никита Бизяев <cap7goo@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-07-01 19:07:59 +00:00
40 changed files with 119 additions and 756 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.*

View File

@@ -5,15 +5,5 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
# Optional. When set, POST /ingest requires x-api-key: <value>. # Optional. When set, POST /ingest requires x-api-key: <value>.
INGEST_API_KEY= INGEST_API_KEY=
# Pool sizing for node-postgres.
PG_POOL_MAX=20
# Maximum points accepted by one POST /ingest request.
INGEST_MAX_BATCH_SIZE=10000
# Poll interval for GET /current/events SSE snapshots. # Poll interval for GET /current/events SSE snapshots.
CURRENT_EVENTS_POLL_MS=1000 CURRENT_EVENTS_POLL_MS=1000
# Graph API defaults.
HISTORY_TARGET_POINTS=2000
HISTORY_MAX_TARGET_POINTS=5000

View File

@@ -2,8 +2,4 @@ PORT=3101
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
INGEST_API_KEY= INGEST_API_KEY=
PG_POOL_MAX=20
INGEST_MAX_BATCH_SIZE=10000
CURRENT_EVENTS_POLL_MS=1000 CURRENT_EVENTS_POLL_MS=1000
HISTORY_TARGET_POINTS=2000
HISTORY_MAX_TARGET_POINTS=5000

View File

@@ -1,18 +1,14 @@
FROM node:22-alpine AS deps FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
FROM node:22-alpine AS build COPY tsconfig*.json ./
WORKDIR /app COPY src ./src
COPY --from=deps /app/node_modules ./node_modules
COPY . . RUN npm run build && npm prune --omit=dev
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"] CMD ["node", "dist/main.js"]

View File

@@ -7,7 +7,7 @@ Lightweight Drill Cloud API for an existing PostgreSQL/TimescaleDB database.
- No migrations. - No migrations.
- No `tag-translation` module. - No `tag-translation` module.
- No alarm log writes. - No alarm log writes.
- No automatic `current`, `edge`, or `tag` upserts during ingest. - No automatic `edge` or `tag` upserts during ingest.
## Database ## Database
@@ -36,24 +36,22 @@ Historical chart ranges use continuous aggregates:
- `POST /ingest` - `POST /ingest`
- `POST /ingest/:edge` - `POST /ingest/:edge`
`POST /ingest` accepts an array of points: `POST /ingest` accepts one point:
```json ```json
[ {
{
"edge": "edge5", "edge": "edge5",
"tag": "hook_weight_1", "tag": "hook_weight_1",
"time": "2026-06-19T00:00:00Z", "timestamp": "2026-06-19T00:00:00Z",
"value": 12.34 "value": 12.34
} }
]
``` ```
`POST /ingest/:edge` accepts a compact edge payload: `POST /ingest/:edge` accepts a compact edge payload:
```json ```json
{ {
"time": "2026-06-19T00:00:00Z", "timestamp": "2026-06-19T00:00:00Z",
"values": { "values": {
"hook_weight_1": 12.34, "hook_weight_1": 12.34,
"rotary_rpm": 80 "rotary_rpm": 80

View File

@@ -5,17 +5,13 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: ${PORT:-3101} PORT: ${PORT}
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
INGEST_API_KEY: ${INGEST_API_KEY:-} INGEST_API_KEY: ${INGEST_API_KEY}
PG_POOL_MAX: ${PG_POOL_MAX:-20} CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS}
INGEST_MAX_BATCH_SIZE: ${INGEST_MAX_BATCH_SIZE:-10000} expose:
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS:-1000} - "3101"
HISTORY_TARGET_POINTS: ${HISTORY_TARGET_POINTS:-2000}
HISTORY_MAX_TARGET_POINTS: ${HISTORY_MAX_TARGET_POINTS:-5000}
ports:
- "${PORT:-3101}:${PORT:-3101}"
networks: networks:
- proxy - proxy

56
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"license": "PERSONAL", "license": "PERSONAL",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -2359,21 +2358,6 @@
} }
} }
}, },
"node_modules/@nestjs/config": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
"license": "MIT",
"dependencies": {
"dotenv": "17.4.1",
"dotenv-expand": "12.0.3",
"lodash": "4.18.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/core": { "node_modules/@nestjs/core": {
"version": "11.1.26", "version": "11.1.26",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz",
@@ -4938,45 +4922,6 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -7375,6 +7320,7 @@
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {

View File

@@ -6,15 +6,14 @@
"license": "PERSONAL", "license": "PERSONAL",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"start": "nest start", "start": "nest start --exec \"node --env-file=.env\"",
"start:dev": "nest start --watch", "start:dev": "nest start --watch --exec \"node --env-file=.env\"",
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\"", "lint": "eslint \"src/**/*.ts\"",
"test": "jest --runInBand" "test": "jest --runInBand"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -25,22 +24,22 @@
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"eslint": "^9.18.0",
"globals": "^16.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0"
"@eslint/js": "^9.18.0",
"eslint": "^9.18.0",
"globals": "^16.0.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@@ -1,5 +1,4 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CurrentModule } from './current/current.module'; import { CurrentModule } from './current/current.module';
import { DbModule } from './db/db.module'; import { DbModule } from './db/db.module';
import { EdgeModule } from './edge/edge.module'; import { EdgeModule } from './edge/edge.module';
@@ -10,7 +9,6 @@ import { TagModule } from './tag/tag.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }),
DbModule, DbModule,
IngestModule, IngestModule,
EdgeModule, EdgeModule,

View File

@@ -1,25 +0,0 @@
import { ConfigService } from '@nestjs/config';
/** Читает числовой параметр конфига и использует резервное значение, если он пустой или неверный. */
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

@@ -1,14 +1,11 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express'; import type { Request } from 'express';
@Injectable() @Injectable()
export class IngestApiKeyGuard implements CanActivate { export class IngestApiKeyGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
/** По умолчанию открывает ingest, но проверяет x-api-key, если задан INGEST_API_KEY. */ /** По умолчанию открывает ingest, но проверяет x-api-key, если задан INGEST_API_KEY. */
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const expectedKey = this.config.get<string>('INGEST_API_KEY'); const expectedKey = process.env.INGEST_API_KEY;
if (!expectedKey) { if (!expectedKey) {
return true; return true;
} }

View File

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

View File

@@ -1,14 +0,0 @@
// Поддерживает оба формата: ?tags=a,b и ?tags=a&tags=b; дубликаты убираются.
/** Приводит повторяющиеся или разделенные запятыми query-параметры к чистому массиву строк. */
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

@@ -1,7 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs'; import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs';
import { getIntegerConfig } from '../common/config-number';
import { CurrentResponseDto } from './dto/current-response.dto'; import { CurrentResponseDto } from './dto/current-response.dto';
import { GetCurrentDto } from './dto/get-current.dto'; import { GetCurrentDto } from './dto/get-current.dto';
import { CurrentService } from './current.service'; import { CurrentService } from './current.service';
@@ -15,21 +13,13 @@ type CurrentSnapshot = {
response: CurrentResponseDto; response: CurrentResponseDto;
}; };
const DEFAULT_CURRENT_EVENTS_POLL_MS = 1_000;
@Injectable() @Injectable()
export class CurrentEventsService { export class CurrentEventsService {
constructor( constructor(private readonly current: CurrentService) {}
private readonly current: CurrentService,
private readonly config: ConfigService,
) {}
/** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */ /** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */
stream(query: GetCurrentDto): Observable<CurrentSseMessage> { stream(query: GetCurrentDto): Observable<CurrentSseMessage> {
const pollMs = Math.max( const pollMs = Number(process.env.CURRENT_EVENTS_POLL_MS);
getIntegerConfig(this.config, 'CURRENT_EVENTS_POLL_MS', DEFAULT_CURRENT_EVENTS_POLL_MS),
250,
);
return timer(0, pollMs).pipe( return timer(0, pollMs).pipe(
switchMap(() => from(this.createSnapshot(query))), switchMap(() => from(this.createSnapshot(query))),

View File

@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { normalizeRequiredText } from '../common/normalize-text';
import { CurrentResponseDto } from './dto/current-response.dto'; import { CurrentResponseDto } from './dto/current-response.dto';
import { GetCurrentDto } from './dto/get-current.dto'; import { GetCurrentDto } from './dto/get-current.dto';
import { createCurrentResponse } from './current.mapper'; import { createCurrentResponse } from './current.mapper';
@@ -11,7 +10,7 @@ export class CurrentService {
/** Нормализует фильтры и возвращает текущие значения по одному edge. */ /** Нормализует фильтры и возвращает текущие значения по одному edge. */
async findByEdge(query: GetCurrentDto): Promise<CurrentResponseDto> { async findByEdge(query: GetCurrentDto): Promise<CurrentResponseDto> {
const edge = normalizeRequiredText(query.edge, 'edge'); const edge = query.edge;
const tags = query.tags?.length ? query.tags : null; const tags = query.tags?.length ? query.tags : null;
const rows = await this.repository.findByEdge(edge, tags); const rows = await this.repository.findByEdge(edge, tags);
return createCurrentResponse(edge, rows); return createCurrentResponse(edge, rows);

View File

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

View File

@@ -1,7 +1,5 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Pool, QueryResult, QueryResultRow } from 'pg'; import { Pool, QueryResult, QueryResultRow } from 'pg';
import { getIntegerConfig } from '../common/config-number';
type DatabaseHealth = { type DatabaseHealth = {
now: Date; now: Date;
@@ -13,18 +11,10 @@ type DatabaseHealth = {
export class DbService implements OnModuleInit, OnModuleDestroy { export class DbService implements OnModuleInit, OnModuleDestroy {
private pool!: Pool; private pool!: Pool;
constructor(private readonly config: ConfigService) {}
/** Создает пул PostgreSQL и проверяет БД до начала обработки запросов. */ /** Создает пул PostgreSQL и проверяет БД до начала обработки запросов. */
async onModuleInit(): Promise<void> { 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({ this.pool = new Pool({
connectionString, connectionString: process.env.DATABASE_URL,
max: getIntegerConfig(this.config, 'PG_POOL_MAX', 20),
idleTimeoutMillis: 30_000, idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000, connectionTimeoutMillis: 5_000,
statement_timeout: 60_000, statement_timeout: 60_000,

View File

@@ -2,11 +2,6 @@ export type EdgeItemDto = {
id: string; id: string;
name: string; name: string;
parentId: string | null; parentId: string | null;
tagIds: string[];
tagCount: number;
currentTagCount: number;
liveTagCount: number;
lastDataAt: Date | null;
}; };
export type EdgeResponseDto = { export type EdgeResponseDto = {

View File

@@ -1,15 +1,11 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { normalizeOptionalText } from '../../common/normalize-text';
export class GetEdgesDto { export class GetEdgesDto {
@IsOptional() @IsOptional()
@Transform(({ value }) => normalizeOptionalText(value))
@IsString() @IsString()
parentId?: string; parentId?: string;
@IsOptional() @IsOptional()
@Transform(({ value }) => normalizeOptionalText(value))
@IsString() @IsString()
search?: string; search?: string;
} }

View File

@@ -8,11 +8,6 @@ export function createEdgeResponse(rows: EdgeRow[]): EdgeResponseDto {
id: row.id, id: row.id,
name: row.name, name: row.name,
parentId: row.parent_id, parentId: row.parent_id,
tagIds: row.tag_ids ?? [],
tagCount: row.tag_count,
currentTagCount: row.current_tag_count,
liveTagCount: row.live_tag_count,
lastDataAt: row.last_data_at,
})), })),
}; };
} }

View File

@@ -6,32 +6,21 @@ import { EdgeRow } from './edge.types';
export class EdgeRepository { export class EdgeRepository {
constructor(private readonly db: DbService) {} constructor(private readonly db: DbService) {}
/** Читает каталог edge с легкими счетчиками свежести из текущих значений. */ /** Reads the edge catalog. */
async findAll(parentId: string | null, search: string | null): Promise<EdgeRow[]> { async findAll(parentId: string | null, search: string | null): Promise<EdgeRow[]> {
const result = await this.db.query<EdgeRow>( const result = await this.db.query<EdgeRow>(
` `
SELECT SELECT
e.id, e.id,
e.name, e.name,
e.parent_id, e.parent_id
e.tag_ids,
cardinality(e.tag_ids)::integer AS tag_count,
count(DISTINCT c.tag)::integer AS current_tag_count,
count(DISTINCT c.tag) FILTER (
WHERE c."updatedAt" >= now() - INTERVAL '30 seconds'
)::integer AS live_tag_count,
max(c."updatedAt") AS last_data_at
FROM edge AS e FROM edge AS e
LEFT JOIN current AS c
ON c.edge = e.id
AND (cardinality(e.tag_ids) = 0 OR c.tag = ANY(e.tag_ids))
WHERE ($1::varchar IS NULL OR e.parent_id = $1::varchar) WHERE ($1::varchar IS NULL OR e.parent_id = $1::varchar)
AND ( AND (
$2::text IS NULL $2::text IS NULL
OR e.id ILIKE $2::text OR e.id ILIKE $2::text
OR e.name ILIKE $2::text OR e.name ILIKE $2::text
) )
GROUP BY e.id, e.name, e.parent_id, e.tag_ids
ORDER BY e.name ASC, e.id ASC ORDER BY e.name ASC, e.id ASC
`, `,
[parentId, search], [parentId, search],

View File

@@ -2,9 +2,4 @@ export type EdgeRow = {
id: string; id: string;
name: string; name: string;
parent_id: string | null; parent_id: string | null;
tag_ids: string[];
tag_count: number;
current_tag_count: number;
live_tag_count: number;
last_data_at: Date | null;
}; };

View File

@@ -1,41 +1,23 @@
import { Transform, Type } from 'class-transformer'; import { IsDateString, IsNotEmpty, IsString } from 'class-validator';
import {
ArrayMaxSize,
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { parseCommaSeparatedList } from '../../common/query-list';
export class GetHistoryDto { export class GetHistoryDto {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
edge!: string; edge!: string;
@IsOptional() @IsNotEmpty()
@Transform(({ value }) => parseCommaSeparatedList(value)) @IsString()
@IsArray() tag!: string;
@ArrayMaxSize(500)
@IsString({ each: true })
tags?: string[];
@IsOptional() @IsNotEmpty()
@IsDateString() @IsDateString()
from?: string; from!: string;
@IsOptional() @IsNotEmpty()
@IsDateString() @IsDateString()
to?: string; to!: string;
@IsOptional() @IsNotEmpty()
@Type(() => Number) @IsString()
@IsNumber() granulate!: string;
@Min(100)
@Max(5000)
targetPoints?: number;
} }

View File

@@ -1,26 +1,11 @@
export type HistoryPointDto = { export type HistoryPointDto = {
t: number; time: Date;
v: number; min_value: number;
min: number; avg_value: number;
avg: number; max_value: number;
max: number; point_count: number;
count: number;
}; };
export type HistorySeriesDto = {
edge: string;
tag: string;
points: HistoryPointDto[];
};
export type HistoryResponseSource = 'empty' | 'latest' | 'raw' | '1m' | '5m' | '1h' | '1d';
export type HistoryResponseDto = { export type HistoryResponseDto = {
edge: string; rows: HistoryPointDto[];
source: HistoryResponseSource;
targetPoints: number;
series: HistorySeriesDto[];
from?: Date;
to?: Date;
resolutionSeconds?: number | null;
}; };

View File

@@ -1,30 +0,0 @@
import { HistorySource } from './history.types';
export const RAW_HISTORY_SOURCE: HistorySource = {
name: 'raw',
table: 'history',
timeColumn: 'timestamp',
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 / Math.max(targetPoints, 1);
if (desiredSeconds < 60) {
return RAW_HISTORY_SOURCE;
}
return (
AGGREGATE_HISTORY_SOURCES.find((source) => source.intervalSeconds >= desiredSeconds) ??
AGGREGATE_HISTORY_SOURCES[AGGREGATE_HISTORY_SOURCES.length - 1]
);
}

View File

@@ -1,55 +0,0 @@
import {
HistoryResponseDto,
HistoryResponseSource,
HistorySeriesDto,
} from './dto/history-response.dto';
import { HistoryRow } from './history.types';
type HistoryResponseOptions = {
edge: string;
source: HistoryResponseSource;
targetPoints: number;
rows?: HistoryRow[];
from?: Date;
to?: Date;
resolutionSeconds?: number | null;
};
/** Превращает плоские строки истории в стабильный API-формат для графиков. */
export function createHistoryResponse(options: HistoryResponseOptions): HistoryResponseDto {
return {
edge: options.edge,
source: options.source,
targetPoints: options.targetPoints,
series: mapHistoryRowsToSeries(options.rows ?? []),
from: options.from,
to: options.to,
resolutionSeconds: options.resolutionSeconds,
};
}
/** Группирует строки БД по edge/tag и переводит Date в миллисекунды Unix. */
function mapHistoryRowsToSeries(rows: HistoryRow[]): HistorySeriesDto[] {
const seriesMap = new Map<string, HistorySeriesDto>();
for (const row of rows) {
const key = `${row.edge}\u0000${row.tag}`;
const series = seriesMap.get(key) ?? {
edge: row.edge,
tag: row.tag,
points: [],
};
series.points.push({
t: row.time.getTime(),
v: row.value,
min: row.min_value,
avg: row.avg_value,
max: row.max_value,
count: row.point_count,
});
seriesMap.set(key, series);
}
return Array.from(seriesMap.values());
}

View File

@@ -1,160 +1,38 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DbService } from '../db/db.service'; import { DbService } from '../db/db.service';
import { HistoryRow, HistorySource } from './history.types'; import { HistoryRow } from './history.types';
@Injectable() @Injectable()
export class HistoryRepository { export class HistoryRepository {
constructor(private readonly db: DbService) {} constructor(private readonly db: DbService) {}
/** Загружает последние N сырых точек истории для каждого запрошенного тега. */ /** Агрегирует сырую историю по одному тегу через TimescaleDB time_bucket. */
async findLatest(edge: string, tags: string[], targetPoints: number): Promise<HistoryRow[]> { async findBucketedRange(
const result = await this.db.query<HistoryRow>(
`
SELECT
latest.edge,
latest.tag,
latest.timestamp AS time,
latest.value,
latest.value AS min_value,
latest.value AS avg_value,
latest.value AS max_value,
1::integer AS point_count
FROM unnest($2::varchar[]) AS requested(tag)
CROSS JOIN LATERAL (
SELECT edge, tag, timestamp, value
FROM history
WHERE edge = $1
AND tag = requested.tag
ORDER BY timestamp DESC
LIMIT $3
) AS latest
ORDER BY latest.tag ASC, latest.timestamp ASC
`,
[edge, tags, targetPoints],
);
return result.rows;
}
/** Загружает сырую историю за диапазон и агрегирует ее в бакеты под бюджет точек. */
async findRawRange(
edge: string, edge: string,
tags: string[], tag: string,
from: Date, from: Date,
to: Date, to: Date,
targetPoints: number, granulate: string,
): Promise<HistoryRow[]> { ): Promise<HistoryRow[]> {
const bucketMillis = this.getBucketMillis(from, to, targetPoints);
const fromMillis = from.getTime();
const result = await this.db.query<HistoryRow>( const result = await this.db.query<HistoryRow>(
` `
WITH filtered AS (
SELECT SELECT
edge, time_bucket($5::interval, timestamp) AS time,
tag,
floor((extract(epoch FROM timestamp) * 1000 - $5) / $6)::bigint AS bucket_id,
value
FROM history
WHERE edge = $1
AND tag = ANY($2::varchar[])
AND timestamp >= $3
AND timestamp <= $4
)
SELECT
edge,
tag,
to_timestamp(($5 + bucket_id * $6) / 1000.0) AS time,
avg(value)::double precision AS value,
min(value)::double precision AS min_value, min(value)::double precision AS min_value,
avg(value)::double precision AS avg_value, avg(value)::double precision AS avg_value,
max(value)::double precision AS max_value, max(value)::double precision AS max_value,
count(*)::integer AS point_count count(*)::integer AS point_count
FROM filtered
GROUP BY edge, tag, bucket_id
ORDER BY tag ASC, time ASC
`,
[edge, tags, from, to, fromMillis, bucketMillis],
);
return result.rows;
}
/** Читает заранее подготовленный aggregate-view с min/avg/max/count по бакетам. */
async findAggregateRange(
edge: string,
tags: string[],
from: Date,
to: Date,
source: HistorySource,
): Promise<HistoryRow[]> {
const result = await this.db.query<HistoryRow>(
`
SELECT
edge,
tag,
${source.timeColumn} AS time,
avg_value AS value,
min_value,
avg_value,
max_value,
point_count
FROM ${source.table}
WHERE edge = $1
AND tag = ANY($2::varchar[])
AND ${source.timeColumn} >= $3
AND ${source.timeColumn} <= $4
ORDER BY tag ASC, ${source.timeColumn} ASC
`,
[edge, tags, from, to],
);
return result.rows;
}
/** Рассчитывает ширину временного бакета так, чтобы на тег было не больше targetPoints точек. */
private getBucketMillis(from: Date, to: Date, targetPoints: number): number {
const durationMillis = Math.max(to.getTime() - from.getTime(), 1);
return Math.max(Math.ceil(durationMillis / Math.max(targetPoints, 1)), 1);
}
/** Берет теги из запроса или ищет связи edge, а затем резервно смотрит саму историю. */
async resolveTags(edge: string, requestedTags?: string[]): Promise<string[]> {
if (requestedTags?.length) {
return requestedTags;
}
const linked = await this.db.query<{ tag: string }>(
`
SELECT DISTINCT tag
FROM (
SELECT unnest(e.tag_ids)::text AS tag
FROM edge AS e
WHERE e.id = $1
UNION
SELECT t.id AS tag
FROM tag AS t
WHERE $1 = ANY(t.edge_ids)
) AS linked_tags
WHERE tag IS NOT NULL
ORDER BY tag ASC
`,
[edge],
);
if (linked.rows.length) {
return linked.rows.map((row) => row.tag);
}
const distinct = await this.db.query<{ tag: string }>(
`
SELECT DISTINCT tag
FROM history FROM history
WHERE edge = $1 WHERE edge = $1
ORDER BY tag ASC AND tag = $2
AND timestamp >= $3
AND timestamp < $4
GROUP BY time
ORDER BY time ASC
`, `,
[edge], [edge, tag, from, to, granulate],
); );
return distinct.rows.map((row) => row.tag); return result.rows;
} }
} }

View File

@@ -1,66 +1,16 @@
import { BadRequestException, Injectable } from '@nestjs/common'; 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 { GetHistoryDto } from './dto/get-history.dto';
import { HistoryResponseDto } from './dto/history-response.dto'; import { HistoryResponseDto } from './dto/history-response.dto';
import { chooseHistorySource } from './history-source';
import { createHistoryResponse } from './history.mapper';
import { HistoryRepository } from './history.repository'; import { HistoryRepository } from './history.repository';
@Injectable() @Injectable()
export class HistoryService { export class HistoryService {
constructor( constructor(private readonly repository: HistoryRepository) {}
private readonly repository: HistoryRepository,
private readonly config: ConfigService,
) {}
/** Определяет теги, выбирает слой истории и собирает API-ответ для графика. */ /** Получает агрегированную историю одного тега за диапазон с заданным шагом bucket. */
async findSeries(query: GetHistoryDto): Promise<HistoryResponseDto> { async findSeries(query: GetHistoryDto): Promise<HistoryResponseDto> {
const edge = normalizeRequiredText(query.edge, 'edge'); const from = new Date(query.from);
const targetPoints = this.getTargetPoints(query.targetPoints); const to = new Date(query.to);
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, source);
return createHistoryResponse({
edge,
from,
to,
source: source.name,
resolutionSeconds: source.intervalSeconds || null,
targetPoints,
rows,
});
}
/** Разбирает опциональный диапазон времени и отклоняет неверные или обратные границы. */
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())) { if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid from/to date.'); throw new BadRequestException('Invalid from/to date.');
@@ -70,14 +20,7 @@ export class HistoryService {
throw new BadRequestException('from cannot be greater than to.'); throw new BadRequestException('from cannot be greater than to.');
} }
return { from, to }; const rows = await this.repository.findBucketedRange(query.edge, query.tag, from, to, query.granulate);
} return { rows };
/** Применяет лимиты точек по умолчанию и максимуму, чтобы ответ истории был удобным для графиков. */
private getTargetPoints(requested?: number): number {
const defaultValue = getIntegerConfig(this.config, 'HISTORY_TARGET_POINTS', 2000);
const maxValue = getIntegerConfig(this.config, 'HISTORY_MAX_TARGET_POINTS', 5000);
const value = requested ?? defaultValue;
return Math.min(Math.max(Math.floor(value), 100), maxValue);
} }
} }

View File

@@ -1,17 +1,5 @@
export type HistorySourceName = 'raw' | '1m' | '5m' | '1h' | '1d';
export type HistorySource = {
name: HistorySourceName;
table: string;
timeColumn: string;
intervalSeconds: number;
};
export type HistoryRow = { export type HistoryRow = {
edge: string;
tag: string;
time: Date; time: Date;
value: number;
min_value: number; min_value: number;
avg_value: number; avg_value: number;
max_value: number; max_value: number;

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Param, ParseArrayPipe, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard'; import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
import { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto'; import { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto';
import { IngestPointDto } from './dto/ingest-point.dto'; import { IngestPointDto } from './dto/ingest-point.dto';
@@ -9,23 +9,19 @@ import { IngestService } from './ingest.service';
export class IngestController { export class IngestController {
constructor(private readonly ingest: IngestService) {} constructor(private readonly ingest: IngestService) {}
/** Принимает массив сырых точек и сохраняет их как записи истории. */ /** Принимает одну сырую точку и сохраняет ее как запись истории/current. */
@Post() @Post()
ingestPoints( ingestPoint(@Body() point: IngestPointDto) {
@Body(new ParseArrayPipe({ items: IngestPointDto })) return this.ingest.ingestPoint(point);
points: IngestPointDto[],
) {
return this.ingest.ingestPoints(points);
} }
/** Принимает компактное тело запроса по edge и разворачивает каждое значение в точку истории. */ /** Принимает компактное тело запроса по edge и сохраняет каждое значение построчно. */
@Post(':edge') @Post(':edge')
ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) { ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) {
const points = Object.entries(body.values).map(([tag, value]) => ({ const points = Object.entries(body.values).map(([tag, value]) => ({
edge, edge,
tag, tag,
value: Number(value), value: Number(value),
time: body.time,
timestamp: body.timestamp, timestamp: body.timestamp,
})); }));

View File

@@ -1,138 +1,49 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getIntegerConfig } from '../common/config-number';
import { normalizeRequiredText } from '../common/normalize-text';
import { DbService } from '../db/db.service'; import { DbService } from '../db/db.service';
import { IngestPointDto } from './dto/ingest-point.dto'; 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 = { type IngestResult = {
processed: number; processed: number;
}; };
@Injectable() @Injectable()
export class IngestService { export class IngestService {
constructor( constructor(private readonly db: DbService) {}
private readonly db: DbService,
private readonly config: ConfigService,
) {}
/** Проверяет батч, пишет сырую историю и обновляет последний снимок current для UI/SSE. */
async ingestPoints(points: IngestPointDto[]): Promise<IngestResult> {
if (!points.length) {
return { processed: 0 };
}
this.assertBatchSize(points.length);
const normalized = points.map((point) => this.normalizePoint(point));
/** Пишет одну входящую точку в history и обновляет текущий снимок current для UI/SSE. */
async ingestPoint(point: IngestPointDto): Promise<IngestResult> {
await this.db.query( await this.db.query(
` `
INSERT INTO history (timestamp, edge, tag, value) INSERT INTO history (timestamp, edge, tag, value)
SELECT time, edge, tag, value VALUES ($1, $2, $3, $4)
FROM unnest(
$1::timestamp[],
$2::varchar[],
$3::varchar[],
$4::double precision[]
) AS point(time, edge, tag, value)
`, `,
[ [point.timestamp, point.edge, point.tag, point.value],
normalized.map((point) => point.time),
normalized.map((point) => point.edge),
normalized.map((point) => point.tag),
normalized.map((point) => point.value),
],
); );
await this.db.query( await this.db.query(
` `
INSERT INTO current (edge, tag, value, "createdAt", "updatedAt") INSERT INTO current (edge, tag, value, "updatedAt")
SELECT DISTINCT ON (edge, tag) VALUES ($1, $2, $3, NOW())
edge,
tag,
value,
time AS "createdAt",
time AS "updatedAt"
FROM unnest(
$1::timestamp[],
$2::varchar[],
$3::varchar[],
$4::double precision[]
) AS point(time, edge, tag, value)
ORDER BY edge ASC, tag ASC, time DESC
ON CONFLICT (edge, tag) ON CONFLICT (edge, tag)
DO UPDATE SET DO UPDATE SET
value = EXCLUDED.value, value = $3,
"updatedAt" = EXCLUDED."updatedAt" "updatedAt" = NOW()
WHERE current."updatedAt" <= EXCLUDED."updatedAt"
`, `,
[ [point.edge, point.tag, point.value],
normalized.map((point) => point.time),
normalized.map((point) => point.edge),
normalized.map((point) => point.tag),
normalized.map((point) => point.value),
],
); );
return { processed: normalized.length }; return { processed: 1 };
} }
/** Защищает API от слишком больших ingest-запросов, занимающих соединение с БД надолго. */ /** Сохраняет несколько точек без batch SQL: каждая точка проходит тот же линейный путь. */
private assertBatchSize(size: number): void { async ingestPoints(points: IngestPointDto[]): Promise<IngestResult> {
const maxBatchSize = Math.max( let processed = 0;
getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE),
1,
);
if (size > maxBatchSize) { for (const point of points) {
throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`); const result = await this.ingestPoint(point);
} processed += result.processed;
} }
/** Приводит входной DTO к нормализованному виду для вставки в history. */ return { processed };
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-время или миллисекунды Unix и отклоняет точки без валидного времени. */
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.');
} }
} }

View File

@@ -8,12 +8,8 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.use(compression()); app.use(compression());
const corsAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS
? process.env.CORS_ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
: true;
app.enableCors({ app.enableCors({
origin: corsAllowedOrigins, origin: process.env.CORS_ALLOWED_ORIGINS,
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'], allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
@@ -27,7 +23,7 @@ async function bootstrap() {
}), }),
); );
await app.listen(process.env.PORT ?? 3101); await app.listen(process.env.PORT as string);
} }
void bootstrap(); void bootstrap();

View File

@@ -1,15 +1,7 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { normalizeOptionalText } from '../../common/normalize-text';
export class GetTagsDto { export class GetTagsDto {
@IsOptional() @IsOptional()
@Transform(({ value }) => normalizeOptionalText(value))
@IsString()
edge?: string;
@IsOptional()
@Transform(({ value }) => normalizeOptionalText(value))
@IsString() @IsString()
search?: string; search?: string;
} }

View File

@@ -2,11 +2,10 @@ export type TagItemDto = {
id: string; id: string;
name: string; name: string;
tagGroup: string | null; tagGroup: string | null;
min: number; min: number | null;
max: number; max: number | null;
comment: string; comment: string;
unitOfMeasurement: string; unitOfMeasurement: string;
edgeIds: string[];
precision: number | null; precision: number | null;
}; };

View File

@@ -12,7 +12,6 @@ export function createTagResponse(rows: TagRow[]): TagResponseDto {
max: row.max, max: row.max,
comment: row.comment, comment: row.comment,
unitOfMeasurement: row.unit_of_measurement, unitOfMeasurement: row.unit_of_measurement,
edgeIds: row.edge_ids ?? [],
precision: row.precision, precision: row.precision,
})), })),
}; };

View File

@@ -6,8 +6,8 @@ import { TagRow } from './tag.types';
export class TagRepository { export class TagRepository {
constructor(private readonly db: DbService) {} constructor(private readonly db: DbService) {}
/** Читает метаданные тегов с учетом связей из tag.edge_ids и edge.tag_ids. */ /** Reads the tag catalog. */
async findAll(edge: string | null, search: string | null): Promise<TagRow[]> { async findAll(search: string | null): Promise<TagRow[]> {
const result = await this.db.query<TagRow>( const result = await this.db.query<TagRow>(
` `
SELECT SELECT
@@ -18,31 +18,20 @@ export class TagRepository {
t.max, t.max,
t.comment, t.comment,
t.unit_of_measurement, t.unit_of_measurement,
t.edge_ids,
t.precision t.precision
FROM tag AS t FROM tag AS t
WHERE ( WHERE (
$1::varchar IS NULL $1::text IS NULL
OR $1::varchar = ANY(t.edge_ids) OR t.id ILIKE $1::text
OR EXISTS ( OR t.name ILIKE $1::text
SELECT 1 OR t.comment ILIKE $1::text
FROM edge AS e
WHERE e.id = $1::varchar
AND t.id = ANY(e.tag_ids)
)
)
AND (
$2::text IS NULL
OR t.id ILIKE $2::text
OR t.name ILIKE $2::text
OR t.comment ILIKE $2::text
) )
ORDER BY ORDER BY
NULLIF(t.tag_group, '') ASC NULLS LAST, NULLIF(t.tag_group, '') ASC NULLS LAST,
t.name ASC, t.name ASC,
t.id ASC t.id ASC
`, `,
[edge, search], [search],
); );
return result.rows; return result.rows;

View File

@@ -10,9 +10,8 @@ export class TagService {
/** Нормализует фильтры и возвращает метаданные тегов для UI. */ /** Нормализует фильтры и возвращает метаданные тегов для UI. */
async findAll(query: GetTagsDto): Promise<TagResponseDto> { async findAll(query: GetTagsDto): Promise<TagResponseDto> {
const edge = query.edge ?? null;
const search = query.search ? `%${query.search}%` : null; const search = query.search ? `%${query.search}%` : null;
const rows = await this.repository.findAll(edge, search); const rows = await this.repository.findAll(search);
return createTagResponse(rows); return createTagResponse(rows);
} }
} }

View File

@@ -2,10 +2,9 @@ export type TagRow = {
id: string; id: string;
name: string; name: string;
tag_group: string | null; tag_group: string | null;
min: number; min: number | null;
max: number; max: number | null;
comment: string; comment: string;
unit_of_measurement: string; unit_of_measurement: string;
edge_ids: string[];
precision: number | null; precision: number | null;
}; };