From a12d490513f9899244c197c1e1222d836ac20ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Fri, 19 Jun 2026 14:59:39 +0300 Subject: [PATCH 01/12] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=B4.=20=D0=A1?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D1=8B=20docker=20=D0=B8=20package=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D1=8B.=20=D0=9F=D0=B5=D1=80=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=BE=20=D0=B2=D0=B7=D0=B0?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20=D0=B1=D0=B4=20-=20=D1=83=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 + .env.example | 4 - .env.local.example | 2 - Dockerfile | 18 ++- docker-compose.yml | 16 ++- package-lock.json | 56 +-------- package.json | 13 +- src/app.module.ts | 2 - src/common/config-number.ts | 25 ---- src/common/ingest-api-key.guard.ts | 5 +- src/common/normalize-text.ts | 22 ---- src/common/query-list.ts | 14 --- src/current/current-events.service.ts | 14 +-- src/current/current.service.ts | 3 +- src/current/dto/get-current.dto.ts | 3 - src/db/db.service.ts | 13 +- src/edge/dto/edge-response.dto.ts | 3 - src/edge/dto/get-edges.dto.ts | 4 - src/edge/edge.mapper.ts | 3 - src/edge/edge.repository.ts | 13 +- src/edge/edge.types.ts | 3 - src/history/dto/get-history.dto.ts | 40 ++---- src/history/dto/history-response.dto.ts | 12 +- src/history/history-source.ts | 30 ----- src/history/history.mapper.ts | 66 ++++------ src/history/history.repository.ts | 157 ++++-------------------- src/history/history.service.ts | 80 +++--------- src/history/history.types.ts | 10 -- src/ingest/ingest.service.ts | 25 +--- src/main.ts | 8 +- src/tag/dto/get-tags.dto.ts | 4 - 31 files changed, 120 insertions(+), 552 deletions(-) create mode 100644 .dockerignore delete mode 100644 src/common/config-number.ts delete mode 100644 src/common/normalize-text.ts delete mode 100644 src/common/query-list.ts delete mode 100644 src/history/history-source.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69e11c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +.env.* diff --git a/.env.example b/.env.example index 6ef862e..2f89709 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,3 @@ INGEST_MAX_BATCH_SIZE=10000 # Poll interval for GET /current/events SSE snapshots. CURRENT_EVENTS_POLL_MS=1000 - -# Graph API defaults. -HISTORY_TARGET_POINTS=2000 -HISTORY_MAX_TARGET_POINTS=5000 diff --git a/.env.local.example b/.env.local.example index 1e57228..064a209 100644 --- a/.env.local.example +++ b/.env.local.example @@ -5,5 +5,3 @@ INGEST_API_KEY= PG_POOL_MAX=20 INGEST_MAX_BATCH_SIZE=10000 CURRENT_EVENTS_POLL_MS=1000 -HISTORY_TARGET_POINTS=2000 -HISTORY_MAX_TARGET_POINTS=5000 diff --git a/Dockerfile b/Dockerfile index 4934f58..189c16d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,14 @@ -FROM node:22-alpine AS deps +FROM node:22-alpine + WORKDIR /app + COPY package*.json ./ RUN npm ci -FROM node:22-alpine AS build -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npm run build +COPY tsconfig*.json ./ +COPY src ./src + +RUN npm run build && npm prune --omit=dev -FROM node:22-alpine AS runtime -WORKDIR /app ENV NODE_ENV=production -COPY package*.json ./ -RUN npm ci --omit=dev -COPY --from=build /app/dist ./dist CMD ["node", "dist/main.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 52c114b..e0aae92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,17 +5,15 @@ services: restart: unless-stopped environment: NODE_ENV: production - PORT: ${PORT:-3101} + PORT: ${PORT} DATABASE_URL: ${DATABASE_URL} - CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} - INGEST_API_KEY: ${INGEST_API_KEY:-} - PG_POOL_MAX: ${PG_POOL_MAX:-20} - INGEST_MAX_BATCH_SIZE: ${INGEST_MAX_BATCH_SIZE:-10000} - CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS:-1000} - HISTORY_TARGET_POINTS: ${HISTORY_TARGET_POINTS:-2000} - HISTORY_MAX_TARGET_POINTS: ${HISTORY_MAX_TARGET_POINTS:-5000} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + INGEST_API_KEY: ${INGEST_API_KEY} + PG_POOL_MAX: ${PG_POOL_MAX} + INGEST_MAX_BATCH_SIZE: ${INGEST_MAX_BATCH_SIZE} + CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS} ports: - - "${PORT:-3101}:${PORT:-3101}" + - "${PORT}:${PORT}" networks: - proxy diff --git a/package-lock.json b/package-lock.json index 069c57a..58c3b1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "PERSONAL", "dependencies": { "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.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": { "version": "11.1.26", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz", @@ -4938,45 +4922,6 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7375,6 +7320,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { diff --git a/package.json b/package.json index 85c43ce..5da45f8 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,14 @@ "license": "PERSONAL", "scripts": { "build": "nest build", - "start": "nest start", - "start:dev": "nest start --watch", + "start": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start", + "start:dev": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start --watch", "start:prod": "node dist/main.js", "lint": "eslint \"src/**/*.ts\"", "test": "jest --runInBand" }, "dependencies": { "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "class-transformer": "^0.5.1", @@ -25,22 +24,22 @@ "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": [ diff --git a/src/app.module.ts b/src/app.module.ts index 91f17fc..8c1f1b8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,4 @@ 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'; @@ -10,7 +9,6 @@ import { TagModule } from './tag/tag.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), DbModule, IngestModule, EdgeModule, diff --git a/src/common/config-number.ts b/src/common/config-number.ts deleted file mode 100644 index f3b264f..0000000 --- a/src/common/config-number.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ConfigService } from '@nestjs/config'; - -/** Читает числовой параметр конфига и использует резервное значение, если он пустой или неверный. */ -export function getNumberConfig( - config: ConfigService, - key: string, - fallback: number, -): number { - const value = config.get(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)); -} diff --git a/src/common/ingest-api-key.guard.ts b/src/common/ingest-api-key.guard.ts index 9f602af..5abebab 100644 --- a/src/common/ingest-api-key.guard.ts +++ b/src/common/ingest-api-key.guard.ts @@ -1,14 +1,11 @@ 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) {} - /** По умолчанию открывает ingest, но проверяет x-api-key, если задан INGEST_API_KEY. */ canActivate(context: ExecutionContext): boolean { - const expectedKey = this.config.get('INGEST_API_KEY'); + const expectedKey = process.env.INGEST_API_KEY; if (!expectedKey) { return true; } diff --git a/src/common/normalize-text.ts b/src/common/normalize-text.ts deleted file mode 100644 index c7955c5..0000000 --- a/src/common/normalize-text.ts +++ /dev/null @@ -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; -} diff --git a/src/common/query-list.ts b/src/common/query-list.ts deleted file mode 100644 index acee713..0000000 --- a/src/common/query-list.ts +++ /dev/null @@ -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; -} diff --git a/src/current/current-events.service.ts b/src/current/current-events.service.ts index e04e94b..c2ed6ef 100644 --- a/src/current/current-events.service.ts +++ b/src/current/current-events.service.ts @@ -1,7 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs'; -import { getIntegerConfig } from '../common/config-number'; import { CurrentResponseDto } from './dto/current-response.dto'; import { GetCurrentDto } from './dto/get-current.dto'; import { CurrentService } from './current.service'; @@ -15,21 +13,13 @@ type CurrentSnapshot = { response: CurrentResponseDto; }; -const DEFAULT_CURRENT_EVENTS_POLL_MS = 1_000; - @Injectable() export class CurrentEventsService { - constructor( - private readonly current: CurrentService, - private readonly config: ConfigService, - ) {} + constructor(private readonly current: CurrentService) {} /** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */ stream(query: GetCurrentDto): Observable { - const pollMs = Math.max( - getIntegerConfig(this.config, 'CURRENT_EVENTS_POLL_MS', DEFAULT_CURRENT_EVENTS_POLL_MS), - 250, - ); + const pollMs = Number(process.env.CURRENT_EVENTS_POLL_MS); return timer(0, pollMs).pipe( switchMap(() => from(this.createSnapshot(query))), diff --git a/src/current/current.service.ts b/src/current/current.service.ts index 6ceb7a5..5c7848a 100644 --- a/src/current/current.service.ts +++ b/src/current/current.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { normalizeRequiredText } from '../common/normalize-text'; import { CurrentResponseDto } from './dto/current-response.dto'; import { GetCurrentDto } from './dto/get-current.dto'; import { createCurrentResponse } from './current.mapper'; @@ -11,7 +10,7 @@ export class CurrentService { /** Нормализует фильтры и возвращает текущие значения по одному edge. */ async findByEdge(query: GetCurrentDto): Promise { - const edge = normalizeRequiredText(query.edge, 'edge'); + const edge = query.edge; const tags = query.tags?.length ? query.tags : null; const rows = await this.repository.findByEdge(edge, tags); return createCurrentResponse(edge, rows); diff --git a/src/current/dto/get-current.dto.ts b/src/current/dto/get-current.dto.ts index d232da6..a6e0056 100644 --- a/src/current/dto/get-current.dto.ts +++ b/src/current/dto/get-current.dto.ts @@ -1,6 +1,4 @@ -import { Transform } from 'class-transformer'; import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { parseCommaSeparatedList } from '../../common/query-list'; export class GetCurrentDto { @IsNotEmpty() @@ -8,7 +6,6 @@ export class GetCurrentDto { edge!: string; @IsOptional() - @Transform(({ value }) => parseCommaSeparatedList(value)) @IsArray() @IsString({ each: true }) tags?: string[]; diff --git a/src/db/db.service.ts b/src/db/db.service.ts index e38f9ab..6e2a59d 100644 --- a/src/db/db.service.ts +++ b/src/db/db.service.ts @@ -1,7 +1,5 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Pool, QueryResult, QueryResultRow } from 'pg'; -import { getIntegerConfig } from '../common/config-number'; type DatabaseHealth = { now: Date; @@ -13,18 +11,11 @@ type DatabaseHealth = { export class DbService implements OnModuleInit, OnModuleDestroy { private pool!: Pool; - constructor(private readonly config: ConfigService) {} - /** Создает пул PostgreSQL и проверяет БД до начала обработки запросов. */ async onModuleInit(): Promise { - const connectionString = this.config.get('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: process.env.DATABASE_URL, + max: Number(process.env.PG_POOL_MAX), idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, statement_timeout: 60_000, diff --git a/src/edge/dto/edge-response.dto.ts b/src/edge/dto/edge-response.dto.ts index a0274fe..76bdb4d 100644 --- a/src/edge/dto/edge-response.dto.ts +++ b/src/edge/dto/edge-response.dto.ts @@ -4,9 +4,6 @@ export type EdgeItemDto = { parentId: string | null; tagIds: string[]; tagCount: number; - currentTagCount: number; - liveTagCount: number; - lastDataAt: Date | null; }; export type EdgeResponseDto = { diff --git a/src/edge/dto/get-edges.dto.ts b/src/edge/dto/get-edges.dto.ts index 8004e4d..a5f25ef 100644 --- a/src/edge/dto/get-edges.dto.ts +++ b/src/edge/dto/get-edges.dto.ts @@ -1,15 +1,11 @@ -import { Transform } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; -import { normalizeOptionalText } from '../../common/normalize-text'; export class GetEdgesDto { @IsOptional() - @Transform(({ value }) => normalizeOptionalText(value)) @IsString() parentId?: string; @IsOptional() - @Transform(({ value }) => normalizeOptionalText(value)) @IsString() search?: string; } diff --git a/src/edge/edge.mapper.ts b/src/edge/edge.mapper.ts index da0fa58..0ce411f 100644 --- a/src/edge/edge.mapper.ts +++ b/src/edge/edge.mapper.ts @@ -10,9 +10,6 @@ export function createEdgeResponse(rows: EdgeRow[]): EdgeResponseDto { 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, })), }; } diff --git a/src/edge/edge.repository.ts b/src/edge/edge.repository.ts index fea71f3..077180d 100644 --- a/src/edge/edge.repository.ts +++ b/src/edge/edge.repository.ts @@ -6,7 +6,7 @@ import { EdgeRow } from './edge.types'; export class EdgeRepository { constructor(private readonly db: DbService) {} - /** Читает каталог edge с легкими счетчиками свежести из текущих значений. */ + /** Читает каталог edge без расчетов по текущим значениям. */ async findAll(parentId: string | null, search: string | null): Promise { const result = await this.db.query( ` @@ -15,23 +15,14 @@ export class EdgeRepository { e.name, 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 + cardinality(e.tag_ids)::integer AS tag_count 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) AND ( $2::text IS NULL OR e.id 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 `, [parentId, search], diff --git a/src/edge/edge.types.ts b/src/edge/edge.types.ts index d9ce984..9d3d41d 100644 --- a/src/edge/edge.types.ts +++ b/src/edge/edge.types.ts @@ -4,7 +4,4 @@ export type EdgeRow = { parent_id: string | null; tag_ids: string[]; tag_count: number; - current_tag_count: number; - live_tag_count: number; - last_data_at: Date | null; }; diff --git a/src/history/dto/get-history.dto.ts b/src/history/dto/get-history.dto.ts index 65d845f..20bf371 100644 --- a/src/history/dto/get-history.dto.ts +++ b/src/history/dto/get-history.dto.ts @@ -1,41 +1,23 @@ -import { Transform, Type } from 'class-transformer'; -import { - ArrayMaxSize, - IsArray, - IsDateString, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - Max, - Min, -} from 'class-validator'; -import { parseCommaSeparatedList } from '../../common/query-list'; +import { IsDateString, IsNotEmpty, IsString } from 'class-validator'; export class GetHistoryDto { @IsNotEmpty() @IsString() edge!: string; - @IsOptional() - @Transform(({ value }) => parseCommaSeparatedList(value)) - @IsArray() - @ArrayMaxSize(500) - @IsString({ each: true }) - tags?: string[]; + @IsNotEmpty() + @IsString() + tag!: string; - @IsOptional() + @IsNotEmpty() @IsDateString() - from?: string; + from!: string; - @IsOptional() + @IsNotEmpty() @IsDateString() - to?: string; + to!: string; - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(100) - @Max(5000) - targetPoints?: number; + @IsNotEmpty() + @IsString() + granulate!: string; } diff --git a/src/history/dto/history-response.dto.ts b/src/history/dto/history-response.dto.ts index 1719f0a..a28cc2c 100644 --- a/src/history/dto/history-response.dto.ts +++ b/src/history/dto/history-response.dto.ts @@ -1,6 +1,5 @@ export type HistoryPointDto = { t: number; - v: number; min: number; avg: number; max: number; @@ -13,14 +12,11 @@ export type HistorySeriesDto = { points: HistoryPointDto[]; }; -export type HistoryResponseSource = 'empty' | 'latest' | 'raw' | '1m' | '5m' | '1h' | '1d'; - export type HistoryResponseDto = { edge: string; - source: HistoryResponseSource; - targetPoints: number; + tag: string; + from: Date; + to: Date; + granulate: string; series: HistorySeriesDto[]; - from?: Date; - to?: Date; - resolutionSeconds?: number | null; }; diff --git a/src/history/history-source.ts b/src/history/history-source.ts deleted file mode 100644 index cd0167d..0000000 --- a/src/history/history-source.ts +++ /dev/null @@ -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] - ); -} diff --git a/src/history/history.mapper.ts b/src/history/history.mapper.ts index 64c49b5..c481fab 100644 --- a/src/history/history.mapper.ts +++ b/src/history/history.mapper.ts @@ -1,55 +1,41 @@ -import { - HistoryResponseDto, - HistoryResponseSource, - HistorySeriesDto, -} from './dto/history-response.dto'; +import { HistoryResponseDto, 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; + tag: string; + from: Date; + to: Date; + granulate: string; + rows: HistoryRow[]; }; -/** Превращает плоские строки истории в стабильный API-формат для графиков. */ +/** Переводит строки агрегированной истории в формат графика min/avg/max. */ export function createHistoryResponse(options: HistoryResponseOptions): HistoryResponseDto { return { edge: options.edge, - source: options.source, - targetPoints: options.targetPoints, - series: mapHistoryRowsToSeries(options.rows ?? []), + tag: options.tag, from: options.from, to: options.to, - resolutionSeconds: options.resolutionSeconds, + granulate: options.granulate, + series: options.rows.length + ? [ + { + edge: options.edge, + tag: options.tag, + points: mapHistoryRowsToPoints(options.rows), + }, + ] + : [], }; } -/** Группирует строки БД по edge/tag и переводит Date в миллисекунды Unix. */ -function mapHistoryRowsToSeries(rows: HistoryRow[]): HistorySeriesDto[] { - const seriesMap = new Map(); - - 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()); +function mapHistoryRowsToPoints(rows: HistoryRow[]): HistorySeriesDto['points'] { + return rows.map((row) => ({ + t: row.time.getTime(), + min: row.min_value, + avg: row.avg_value, + max: row.max_value, + count: row.point_count, + })); } diff --git a/src/history/history.repository.ts b/src/history/history.repository.ts index 7afa2c0..1fee3e1 100644 --- a/src/history/history.repository.ts +++ b/src/history/history.repository.ts @@ -1,160 +1,45 @@ import { Injectable } from '@nestjs/common'; import { DbService } from '../db/db.service'; -import { HistoryRow, HistorySource } from './history.types'; +import { HistoryRow } from './history.types'; @Injectable() export class HistoryRepository { constructor(private readonly db: DbService) {} - /** Загружает последние N сырых точек истории для каждого запрошенного тега. */ - async findLatest(edge: string, tags: string[], targetPoints: number): Promise { - const result = await this.db.query( - ` - 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( + /** Агрегирует сырую историю по одному тегу через TimescaleDB time_bucket. */ + async findBucketedRange( edge: string, - tags: string[], + tag: string, from: Date, to: Date, - targetPoints: number, + granulate: string, ): Promise { - const bucketMillis = this.getBucketMillis(from, to, targetPoints); - const fromMillis = from.getTime(); const result = await this.db.query( ` - WITH filtered AS ( - SELECT - edge, - 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, + $1::varchar AS edge, + $2::varchar AS tag, + bucket AS time, min(value)::double precision AS min_value, avg(value)::double precision AS avg_value, max(value)::double precision AS max_value, 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 { - const result = await this.db.query( - ` - 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 { - 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 + SELECT + time_bucket($5::interval, timestamp) AS bucket, + value + FROM history + WHERE edge = $1 + AND tag = $2 + AND timestamp >= $3 + AND timestamp < $4 + ) AS bucketed + GROUP BY bucket + ORDER BY bucket ASC `, - [edge], + [edge, tag, from, to, granulate], ); - if (linked.rows.length) { - return linked.rows.map((row) => row.tag); - } - - const distinct = await this.db.query<{ tag: string }>( - ` - SELECT DISTINCT tag - FROM history - WHERE edge = $1 - ORDER BY tag ASC - `, - [edge], - ); - - return distinct.rows.map((row) => row.tag); + return result.rows; } } diff --git a/src/history/history.service.ts b/src/history/history.service.ts index 4bf6799..8e80f85 100644 --- a/src/history/history.service.ts +++ b/src/history/history.service.ts @@ -1,66 +1,17 @@ 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 { HistoryResponseDto } from './dto/history-response.dto'; -import { chooseHistorySource } from './history-source'; import { createHistoryResponse } from './history.mapper'; import { HistoryRepository } from './history.repository'; @Injectable() export class HistoryService { - constructor( - private readonly repository: HistoryRepository, - private readonly config: ConfigService, - ) {} + constructor(private readonly repository: HistoryRepository) {} - /** Определяет теги, выбирает слой истории и собирает API-ответ для графика. */ + /** Получает агрегированную историю одного тега за диапазон с заданным шагом bucket. */ async findSeries(query: GetHistoryDto): Promise { - 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, 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(); + const from = new Date(query.from); + const to = new Date(query.to); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { throw new BadRequestException('Invalid from/to date.'); @@ -70,14 +21,21 @@ export class HistoryService { 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, + ); - /** Применяет лимиты точек по умолчанию и максимуму, чтобы ответ истории был удобным для графиков. */ - 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); + return createHistoryResponse({ + edge: query.edge, + tag: query.tag, + from, + to, + granulate: query.granulate, + rows, + }); } } diff --git a/src/history/history.types.ts b/src/history/history.types.ts index 246320d..470112b 100644 --- a/src/history/history.types.ts +++ b/src/history/history.types.ts @@ -1,17 +1,7 @@ -export type HistorySourceName = 'raw' | '1m' | '5m' | '1h' | '1d'; - -export type HistorySource = { - name: HistorySourceName; - table: string; - timeColumn: string; - intervalSeconds: number; -}; - export type HistoryRow = { edge: string; tag: string; time: Date; - value: number; min_value: number; avg_value: number; max_value: number; diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index a6d2a37..dd575f4 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -1,12 +1,7 @@ import { BadRequestException, 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 { IngestPointDto } from './dto/ingest-point.dto'; -const DEFAULT_MAX_BATCH_SIZE = 10_000; - type NormalizedPoint = { time: Date; edge: string; @@ -20,10 +15,7 @@ type IngestResult = { @Injectable() export class IngestService { - constructor( - private readonly db: DbService, - private readonly config: ConfigService, - ) {} + constructor(private readonly db: DbService) {} /** Проверяет батч, пишет сырую историю и обновляет последний снимок current для UI/SSE. */ async ingestPoints(points: IngestPointDto[]): Promise { @@ -89,10 +81,7 @@ export class IngestService { /** Защищает API от слишком больших ingest-запросов, занимающих соединение с БД надолго. */ private assertBatchSize(size: number): void { - const maxBatchSize = Math.max( - getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE), - 1, - ); + const maxBatchSize = Number(process.env.INGEST_MAX_BATCH_SIZE); if (size > maxBatchSize) { throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`); @@ -101,17 +90,11 @@ export class IngestService { /** Приводит входной DTO к нормализованному виду для вставки в history. */ 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, + edge: point.edge, + tag: point.tag, value: point.value, time, }; diff --git a/src/main.ts b/src/main.ts index 251a044..f190323 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,12 +8,8 @@ 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; - app.enableCors({ - origin: corsAllowedOrigins, + origin: process.env.CORS_ALLOWED_ORIGINS, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], 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(); diff --git a/src/tag/dto/get-tags.dto.ts b/src/tag/dto/get-tags.dto.ts index f484845..ae8e5b6 100644 --- a/src/tag/dto/get-tags.dto.ts +++ b/src/tag/dto/get-tags.dto.ts @@ -1,15 +1,11 @@ -import { Transform } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; -import { normalizeOptionalText } from '../../common/normalize-text'; export class GetTagsDto { @IsOptional() - @Transform(({ value }) => normalizeOptionalText(value)) @IsString() edge?: string; @IsOptional() - @Transform(({ value }) => normalizeOptionalText(value)) @IsString() search?: string; } -- 2.49.1 From 8819adf42bd0f10d2e2017b493c8c3bcbd5e2f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 21 Jun 2026 02:21:17 +0300 Subject: [PATCH 02/12] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=83=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=20=D0=B2=20ingest.service.=20=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B5=D0=BD=D1=83=D0=B6=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20normalizePoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ingest/ingest.service.ts | 37 ++++++------------------------------ 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index dd575f4..19c2494 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -25,7 +25,12 @@ export class IngestService { this.assertBatchSize(points.length); - const normalized = points.map((point) => this.normalizePoint(point)); + const normalized: NormalizedPoint[] = points.map((point) => ({ + edge: point.edge, + tag: point.tag, + value: point.value, + time: new Date((point.time ?? point.timestamp) as string | number), + })); await this.db.query( ` @@ -88,34 +93,4 @@ export class IngestService { } } - /** Приводит входной DTO к нормализованному виду для вставки в history. */ - private normalizePoint(point: IngestPointDto): NormalizedPoint { - const time = this.parsePointTime(point); - - return { - edge: point.edge, - tag: point.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.'); - } } -- 2.49.1 From 919268c9874160dcbd3253371483e87386f01d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 21 Jun 2026 02:24:02 +0300 Subject: [PATCH 03/12] =?UTF-8?q?=D0=92=D1=8B=D1=80=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=83=D0=B6=D0=BD=D1=8B=D0=B9=20asserBatchSize.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 --- .env.local.example | 1 - docker-compose.yml | 1 - src/ingest/ingest.service.ts | 14 +------------- 4 files changed, 1 insertion(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 2f89709..ec18ece 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,5 @@ 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. CURRENT_EVENTS_POLL_MS=1000 diff --git a/.env.local.example b/.env.local.example index 064a209..05a909f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,5 +3,4 @@ DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 INGEST_API_KEY= PG_POOL_MAX=20 -INGEST_MAX_BATCH_SIZE=10000 CURRENT_EVENTS_POLL_MS=1000 diff --git a/docker-compose.yml b/docker-compose.yml index e0aae92..d9b4c61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,6 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} INGEST_API_KEY: ${INGEST_API_KEY} PG_POOL_MAX: ${PG_POOL_MAX} - INGEST_MAX_BATCH_SIZE: ${INGEST_MAX_BATCH_SIZE} CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS} ports: - "${PORT}:${PORT}" diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index 19c2494..aa7a126 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DbService } from '../db/db.service'; import { IngestPointDto } from './dto/ingest-point.dto'; @@ -23,8 +23,6 @@ export class IngestService { return { processed: 0 }; } - this.assertBatchSize(points.length); - const normalized: NormalizedPoint[] = points.map((point) => ({ edge: point.edge, tag: point.tag, @@ -83,14 +81,4 @@ export class IngestService { return { processed: normalized.length }; } - - /** Защищает API от слишком больших ingest-запросов, занимающих соединение с БД надолго. */ - private assertBatchSize(size: number): void { - const maxBatchSize = Number(process.env.INGEST_MAX_BATCH_SIZE); - - if (size > maxBatchSize) { - throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`); - } - } - } -- 2.49.1 From 3346e572d296729091d8d0f33d6b80ebe4a729ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 21 Jun 2026 02:31:58 +0300 Subject: [PATCH 04/12] =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20findBucketed?= =?UTF-8?q?Range.=20=D0=A3=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=B8=D0=BB=20sql?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81.=20=D0=A1=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=20history.types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/history/history.repository.ts | 23 ++++++++--------------- src/history/history.types.ts | 2 -- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/history/history.repository.ts b/src/history/history.repository.ts index 1fee3e1..e36592c 100644 --- a/src/history/history.repository.ts +++ b/src/history/history.repository.ts @@ -17,25 +17,18 @@ export class HistoryRepository { const result = await this.db.query( ` SELECT - $1::varchar AS edge, - $2::varchar AS tag, - bucket AS time, + time_bucket($5::interval, timestamp) AS time, min(value)::double precision AS min_value, avg(value)::double precision AS avg_value, max(value)::double precision AS max_value, count(*)::integer AS point_count - FROM ( - SELECT - time_bucket($5::interval, timestamp) AS bucket, - value - FROM history - WHERE edge = $1 - AND tag = $2 - AND timestamp >= $3 - AND timestamp < $4 - ) AS bucketed - GROUP BY bucket - ORDER BY bucket ASC + FROM history + WHERE edge = $1 + AND tag = $2 + AND timestamp >= $3 + AND timestamp < $4 + GROUP BY time + ORDER BY time ASC `, [edge, tag, from, to, granulate], ); diff --git a/src/history/history.types.ts b/src/history/history.types.ts index 470112b..5fcf7dc 100644 --- a/src/history/history.types.ts +++ b/src/history/history.types.ts @@ -1,6 +1,4 @@ export type HistoryRow = { - edge: string; - tag: string; time: Date; min_value: number; avg_value: number; -- 2.49.1 From 59e978f8cda90cecc4aed7b6bdd37846955fcdf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 21 Jun 2026 02:51:32 +0300 Subject: [PATCH 05/12] =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20package.json?= =?UTF-8?q?=20=D0=B2=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0?= =?UTF-8?q?.=20=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D0=B5=D1=89=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=83=20=D0=BB=D0=B8=D1=88=D0=BD=D1=8E=D1=8E?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=83=D1=8E?= =?UTF-8?q?=20=D1=81=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=BE=D0=BC=20pg=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9.=20Default=20value=20=D1=83=20pg=20poo?= =?UTF-8?q?l=20=3D=2010,=20=D1=87=D0=B5=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BB?= =?UTF-8?q?=D0=B6=D0=BD=D0=BE=20=D1=85=D0=B2=D0=B0=D1=82=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 --- .env.local.example | 1 - docker-compose.yml | 1 - package.json | 4 ++-- src/db/db.service.ts | 1 - 5 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index ec18ece..9200741 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,5 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 # Optional. When set, POST /ingest requires x-api-key: . INGEST_API_KEY= -# Pool sizing for node-postgres. -PG_POOL_MAX=20 - # Poll interval for GET /current/events SSE snapshots. CURRENT_EVENTS_POLL_MS=1000 diff --git a/.env.local.example b/.env.local.example index 05a909f..d52377c 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,5 +2,4 @@ PORT=3101 DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 INGEST_API_KEY= -PG_POOL_MAX=20 CURRENT_EVENTS_POLL_MS=1000 diff --git a/docker-compose.yml b/docker-compose.yml index d9b4c61..7f1b8ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,6 @@ services: DATABASE_URL: ${DATABASE_URL} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} INGEST_API_KEY: ${INGEST_API_KEY} - PG_POOL_MAX: ${PG_POOL_MAX} CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS} ports: - "${PORT}:${PORT}" diff --git a/package.json b/package.json index 5da45f8..f594f43 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "license": "PERSONAL", "scripts": { "build": "nest build", - "start": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start", - "start:dev": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start --watch", + "start": "nest start --exec \"node --env-file=.env\"", + "start:dev": "nest start --watch --exec \"node --env-file=.env\"", "start:prod": "node dist/main.js", "lint": "eslint \"src/**/*.ts\"", "test": "jest --runInBand" diff --git a/src/db/db.service.ts b/src/db/db.service.ts index 6e2a59d..32c1a09 100644 --- a/src/db/db.service.ts +++ b/src/db/db.service.ts @@ -15,7 +15,6 @@ export class DbService implements OnModuleInit, OnModuleDestroy { async onModuleInit(): Promise { this.pool = new Pool({ connectionString: process.env.DATABASE_URL, - max: Number(process.env.PG_POOL_MAX), idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, statement_timeout: 60_000, -- 2.49.1 From ce493f8fb63d40c865450ba71e0a3f5bca0f8899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 21 Jun 2026 03:41:52 +0300 Subject: [PATCH 06/12] =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BA=20(tag.min=20=D0=B8=20tag.max)=20-=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D1=83=D1=81=D1=82=D0=B8=D0=BB=20null=20?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20db.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tag/dto/tag-response.dto.ts | 4 ++-- src/tag/tag.types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tag/dto/tag-response.dto.ts b/src/tag/dto/tag-response.dto.ts index 6a7f94d..d9f9f8a 100644 --- a/src/tag/dto/tag-response.dto.ts +++ b/src/tag/dto/tag-response.dto.ts @@ -2,8 +2,8 @@ export type TagItemDto = { id: string; name: string; tagGroup: string | null; - min: number; - max: number; + min: number | null; + max: number | null; comment: string; unitOfMeasurement: string; edgeIds: string[]; diff --git a/src/tag/tag.types.ts b/src/tag/tag.types.ts index b0357b3..751f007 100644 --- a/src/tag/tag.types.ts +++ b/src/tag/tag.types.ts @@ -2,8 +2,8 @@ export type TagRow = { id: string; name: string; tag_group: string | null; - min: number; - max: number; + min: number | null; + max: number | null; comment: string; unit_of_measurement: string; edge_ids: string[]; -- 2.49.1 From 05c13f36a4ff5e476241c65c09dff038fd411f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Mon, 22 Jun 2026 02:50:24 +0300 Subject: [PATCH 07/12] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=B8=D0=B5=D0=BC=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B8=D0=B7=20mqtt-ingest=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9.=20=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20batch=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5.=20?= =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=20update=20=D0=B2=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=83=20current.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 ++++----- src/ingest/ingest.controller.ts | 13 +++---- src/ingest/ingest.service.ts | 67 +++++++++++---------------------- 3 files changed, 36 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 0490676..03c4403 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Lightweight Drill Cloud API for an existing PostgreSQL/TimescaleDB database. - No migrations. - No `tag-translation` module. - No alarm log writes. -- No automatic `current`, `edge`, or `tag` upserts during ingest. +- No automatic `edge` or `tag` upserts during ingest. ## Database @@ -36,17 +36,15 @@ Historical chart ranges use continuous aggregates: - `POST /ingest` - `POST /ingest/:edge` -`POST /ingest` accepts an array of points: +`POST /ingest` accepts one point: ```json -[ - { - "edge": "edge5", - "tag": "hook_weight_1", - "time": "2026-06-19T00:00:00Z", - "value": 12.34 - } -] +{ + "edge": "edge5", + "tag": "hook_weight_1", + "time": "2026-06-19T00:00:00Z", + "value": 12.34 +} ``` `POST /ingest/:edge` accepts a compact edge payload: diff --git a/src/ingest/ingest.controller.ts b/src/ingest/ingest.controller.ts index 8706690..02d25e4 100644 --- a/src/ingest/ingest.controller.ts +++ b/src/ingest/ingest.controller.ts @@ -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 { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto'; import { IngestPointDto } from './dto/ingest-point.dto'; @@ -9,16 +9,13 @@ import { IngestService } from './ingest.service'; export class IngestController { constructor(private readonly ingest: IngestService) {} - /** Принимает массив сырых точек и сохраняет их как записи истории. */ + /** Принимает одну сырую точку и сохраняет ее как запись истории/current. */ @Post() - ingestPoints( - @Body(new ParseArrayPipe({ items: IngestPointDto })) - points: IngestPointDto[], - ) { - return this.ingest.ingestPoints(points); + ingestPoint(@Body() point: IngestPointDto) { + return this.ingest.ingestPoint(point); } - /** Принимает компактное тело запроса по edge и разворачивает каждое значение в точку истории. */ + /** Принимает компактное тело запроса по edge и сохраняет каждое значение построчно. */ @Post(':edge') ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) { const points = Object.entries(body.values).map(([tag, value]) => ({ diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index aa7a126..e44a44f 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -17,68 +17,47 @@ type IngestResult = { export class IngestService { constructor(private readonly db: DbService) {} - /** Проверяет батч, пишет сырую историю и обновляет последний снимок current для UI/SSE. */ - async ingestPoints(points: IngestPointDto[]): Promise { - if (!points.length) { - return { processed: 0 }; - } - - const normalized: NormalizedPoint[] = points.map((point) => ({ + /** Пишет одну сырую точку в history и обновляет текущий снимок current для UI/SSE. */ + async ingestPoint(point: IngestPointDto): Promise { + const normalized: NormalizedPoint = { edge: point.edge, tag: point.tag, value: point.value, time: new Date((point.time ?? point.timestamp) as string | number), - })); + }; await this.db.query( ` INSERT INTO history (timestamp, edge, tag, value) - SELECT time, edge, tag, value - FROM unnest( - $1::timestamp[], - $2::varchar[], - $3::varchar[], - $4::double precision[] - ) AS point(time, edge, tag, value) + VALUES ($1, $2, $3, $4) `, - [ - normalized.map((point) => point.time), - normalized.map((point) => point.edge), - normalized.map((point) => point.tag), - normalized.map((point) => point.value), - ], + [normalized.time, normalized.edge, normalized.tag, normalized.value], ); await this.db.query( ` INSERT INTO current (edge, tag, value, "createdAt", "updatedAt") - SELECT DISTINCT ON (edge, tag) - 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 + VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (edge, tag) DO UPDATE SET - value = EXCLUDED.value, - "updatedAt" = EXCLUDED."updatedAt" - WHERE current."updatedAt" <= EXCLUDED."updatedAt" + value = $3, + "updatedAt" = NOW() `, - [ - normalized.map((point) => point.time), - normalized.map((point) => point.edge), - normalized.map((point) => point.tag), - normalized.map((point) => point.value), - ], + [normalized.edge, normalized.tag, normalized.value], ); - return { processed: normalized.length }; + return { processed: 1 }; + } + + /** Сохраняет несколько точек без batch SQL: каждая точка проходит тот же линейный путь. */ + async ingestPoints(points: IngestPointDto[]): Promise { + let processed = 0; + + for (const point of points) { + const result = await this.ingestPoint(point); + processed += result.processed; + } + + return { processed }; } } -- 2.49.1 From 37ea9420c86f13bad1123d13b140fdea2b77a4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Mon, 22 Jun 2026 03:20:28 +0300 Subject: [PATCH 08/12] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D1=8E=D1=8E=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BD=D0=B0=20ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/history/dto/history-response.dto.ts | 23 ++++---------- src/history/history.mapper.ts | 41 ------------------------- src/history/history.service.ts | 19 ++---------- 3 files changed, 8 insertions(+), 75 deletions(-) delete mode 100644 src/history/history.mapper.ts diff --git a/src/history/dto/history-response.dto.ts b/src/history/dto/history-response.dto.ts index a28cc2c..83c6d0f 100644 --- a/src/history/dto/history-response.dto.ts +++ b/src/history/dto/history-response.dto.ts @@ -1,22 +1,11 @@ export type HistoryPointDto = { - t: number; - min: number; - avg: number; - max: number; - count: number; -}; - -export type HistorySeriesDto = { - edge: string; - tag: string; - points: HistoryPointDto[]; + time: Date; + min_value: number; + avg_value: number; + max_value: number; + point_count: number; }; export type HistoryResponseDto = { - edge: string; - tag: string; - from: Date; - to: Date; - granulate: string; - series: HistorySeriesDto[]; + rows: HistoryPointDto[]; }; diff --git a/src/history/history.mapper.ts b/src/history/history.mapper.ts deleted file mode 100644 index c481fab..0000000 --- a/src/history/history.mapper.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { HistoryResponseDto, HistorySeriesDto } from './dto/history-response.dto'; -import { HistoryRow } from './history.types'; - -type HistoryResponseOptions = { - edge: string; - tag: string; - from: Date; - to: Date; - granulate: string; - rows: HistoryRow[]; -}; - -/** Переводит строки агрегированной истории в формат графика min/avg/max. */ -export function createHistoryResponse(options: HistoryResponseOptions): HistoryResponseDto { - return { - edge: options.edge, - tag: options.tag, - from: options.from, - to: options.to, - granulate: options.granulate, - series: options.rows.length - ? [ - { - edge: options.edge, - tag: options.tag, - points: mapHistoryRowsToPoints(options.rows), - }, - ] - : [], - }; -} - -function mapHistoryRowsToPoints(rows: HistoryRow[]): HistorySeriesDto['points'] { - return rows.map((row) => ({ - t: row.time.getTime(), - min: row.min_value, - avg: row.avg_value, - max: row.max_value, - count: row.point_count, - })); -} diff --git a/src/history/history.service.ts b/src/history/history.service.ts index 8e80f85..a1c7719 100644 --- a/src/history/history.service.ts +++ b/src/history/history.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { GetHistoryDto } from './dto/get-history.dto'; import { HistoryResponseDto } from './dto/history-response.dto'; -import { createHistoryResponse } from './history.mapper'; import { HistoryRepository } from './history.repository'; @Injectable() @@ -21,21 +20,7 @@ export class HistoryService { throw new BadRequestException('from cannot be greater than to.'); } - const rows = await this.repository.findBucketedRange( - query.edge, - query.tag, - from, - to, - query.granulate, - ); - - return createHistoryResponse({ - edge: query.edge, - tag: query.tag, - from, - to, - granulate: query.granulate, - rows, - }); + const rows = await this.repository.findBucketedRange(query.edge, query.tag, from, to, query.granulate); + return { rows }; } } -- 2.49.1 From a80019cef58ca91f332fdea179f4a5a3e7f43b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Mon, 22 Jun 2026 03:29:51 +0300 Subject: [PATCH 09/12] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D1=8E=D1=8E=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- src/ingest/dto/ingest-edge-values.dto.ts | 11 ++--------- src/ingest/dto/ingest-point.dto.ts | 10 ++-------- src/ingest/ingest.controller.ts | 1 - src/ingest/ingest.service.ts | 20 +++----------------- 5 files changed, 9 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 03c4403..acf07e8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Historical chart ranges use continuous aggregates: { "edge": "edge5", "tag": "hook_weight_1", - "time": "2026-06-19T00:00:00Z", + "timestamp": "2026-06-19T00:00:00Z", "value": 12.34 } ``` @@ -51,7 +51,7 @@ Historical chart ranges use continuous aggregates: ```json { - "time": "2026-06-19T00:00:00Z", + "timestamp": "2026-06-19T00:00:00Z", "values": { "hook_weight_1": 12.34, "rotary_rpm": 80 diff --git a/src/ingest/dto/ingest-edge-values.dto.ts b/src/ingest/dto/ingest-edge-values.dto.ts index dbe180a..fabdd5a 100644 --- a/src/ingest/dto/ingest-edge-values.dto.ts +++ b/src/ingest/dto/ingest-edge-values.dto.ts @@ -1,15 +1,8 @@ -import { Type } from 'class-transformer'; -import { IsDateString, IsNumber, IsObject, IsOptional } from 'class-validator'; +import { IsDateString, IsObject } from 'class-validator'; export class IngestEdgeValuesDto { - @IsOptional() @IsDateString() - time?: string; - - @IsOptional() - @Type(() => Number) - @IsNumber() - timestamp?: number; + timestamp!: string; @IsObject() values!: Record; diff --git a/src/ingest/dto/ingest-point.dto.ts b/src/ingest/dto/ingest-point.dto.ts index b51de41..8d1d7c4 100644 --- a/src/ingest/dto/ingest-point.dto.ts +++ b/src/ingest/dto/ingest-point.dto.ts @@ -1,5 +1,5 @@ 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 { @IsNotEmpty() @@ -10,14 +10,8 @@ export class IngestPointDto { @IsString() tag!: string; - @IsOptional() @IsDateString() - time?: string; - - @IsOptional() - @Type(() => Number) - @IsNumber() - timestamp?: number; + timestamp!: string; @Type(() => Number) @IsNumber() diff --git a/src/ingest/ingest.controller.ts b/src/ingest/ingest.controller.ts index 02d25e4..befcc55 100644 --- a/src/ingest/ingest.controller.ts +++ b/src/ingest/ingest.controller.ts @@ -22,7 +22,6 @@ export class IngestController { edge, tag, value: Number(value), - time: body.time, timestamp: body.timestamp, })); diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index e44a44f..c93e337 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -2,13 +2,6 @@ import { Injectable } from '@nestjs/common'; import { DbService } from '../db/db.service'; import { IngestPointDto } from './dto/ingest-point.dto'; -type NormalizedPoint = { - time: Date; - edge: string; - tag: string; - value: number; -}; - type IngestResult = { processed: number; }; @@ -17,21 +10,14 @@ type IngestResult = { export class IngestService { constructor(private readonly db: DbService) {} - /** Пишет одну сырую точку в history и обновляет текущий снимок current для UI/SSE. */ + /** Пишет одну входящую точку в history и обновляет текущий снимок current для UI/SSE. */ async ingestPoint(point: IngestPointDto): Promise { - const normalized: NormalizedPoint = { - edge: point.edge, - tag: point.tag, - value: point.value, - time: new Date((point.time ?? point.timestamp) as string | number), - }; - await this.db.query( ` INSERT INTO history (timestamp, edge, tag, value) VALUES ($1, $2, $3, $4) `, - [normalized.time, normalized.edge, normalized.tag, normalized.value], + [point.timestamp, point.edge, point.tag, point.value], ); await this.db.query( @@ -43,7 +29,7 @@ export class IngestService { value = $3, "updatedAt" = NOW() `, - [normalized.edge, normalized.tag, normalized.value], + [point.edge, point.tag, point.value], ); return { processed: 1 }; -- 2.49.1 From cf507037a8f09d844670728b62faf46b6424d87d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Mon, 22 Jun 2026 22:03:32 +0300 Subject: [PATCH 10/12] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20createdA?= =?UTF-8?q?t=20=D0=B8=D0=B7=20sql=20=D0=BF=D0=BE=20insert=20current.=20?= =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BB=D0=BE=D0=B6=D0=B8=D0=BB=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=20default=20current=5Ftimestamp.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ingest/ingest.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index c93e337..3c1d3f6 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -22,8 +22,8 @@ export class IngestService { await this.db.query( ` - INSERT INTO current (edge, tag, value, "createdAt", "updatedAt") - VALUES ($1, $2, $3, NOW(), NOW()) + INSERT INTO current (edge, tag, value, "updatedAt") + VALUES ($1, $2, $3, NOW()) ON CONFLICT (edge, tag) DO UPDATE SET value = $3, -- 2.49.1 From 14a75acb1578345dc2434c739acdb2965753f9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Tue, 23 Jun 2026 13:53:04 +0300 Subject: [PATCH 11/12] add expose to docker-compose --- docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7f1b8ba..087581a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,11 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} INGEST_API_KEY: ${INGEST_API_KEY} CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS} - ports: - - "${PORT}:${PORT}" + expose: + - "3101" networks: - proxy networks: proxy: - external: true + external: true \ No newline at end of file -- 2.49.1 From cc3c5fab060717f3d1816e8efe555fca91ab6603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Wed, 1 Jul 2026 21:50:05 +0300 Subject: [PATCH 12/12] =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20dto=20?= =?UTF-8?q?=D0=B8=20sql=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BD=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B0=D0=B7=D1=8B=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20cloud-beta.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/edge/dto/edge-response.dto.ts | 2 -- src/edge/edge.mapper.ts | 2 -- src/edge/edge.repository.ts | 6 ++---- src/edge/edge.types.ts | 2 -- src/tag/dto/get-tags.dto.ts | 4 ---- src/tag/dto/tag-response.dto.ts | 1 - src/tag/tag.mapper.ts | 1 - src/tag/tag.repository.ts | 25 +++++++------------------ src/tag/tag.service.ts | 3 +-- src/tag/tag.types.ts | 1 - 10 files changed, 10 insertions(+), 37 deletions(-) diff --git a/src/edge/dto/edge-response.dto.ts b/src/edge/dto/edge-response.dto.ts index 76bdb4d..d38b54e 100644 --- a/src/edge/dto/edge-response.dto.ts +++ b/src/edge/dto/edge-response.dto.ts @@ -2,8 +2,6 @@ export type EdgeItemDto = { id: string; name: string; parentId: string | null; - tagIds: string[]; - tagCount: number; }; export type EdgeResponseDto = { diff --git a/src/edge/edge.mapper.ts b/src/edge/edge.mapper.ts index 0ce411f..3230f9e 100644 --- a/src/edge/edge.mapper.ts +++ b/src/edge/edge.mapper.ts @@ -8,8 +8,6 @@ export function createEdgeResponse(rows: EdgeRow[]): EdgeResponseDto { id: row.id, name: row.name, parentId: row.parent_id, - tagIds: row.tag_ids ?? [], - tagCount: row.tag_count, })), }; } diff --git a/src/edge/edge.repository.ts b/src/edge/edge.repository.ts index 077180d..dc476b8 100644 --- a/src/edge/edge.repository.ts +++ b/src/edge/edge.repository.ts @@ -6,16 +6,14 @@ import { EdgeRow } from './edge.types'; export class EdgeRepository { constructor(private readonly db: DbService) {} - /** Читает каталог edge без расчетов по текущим значениям. */ + /** Reads the edge catalog. */ async findAll(parentId: string | null, search: string | null): Promise { const result = await this.db.query( ` SELECT e.id, e.name, - e.parent_id, - e.tag_ids, - cardinality(e.tag_ids)::integer AS tag_count + e.parent_id FROM edge AS e WHERE ($1::varchar IS NULL OR e.parent_id = $1::varchar) AND ( diff --git a/src/edge/edge.types.ts b/src/edge/edge.types.ts index 9d3d41d..2d34e88 100644 --- a/src/edge/edge.types.ts +++ b/src/edge/edge.types.ts @@ -2,6 +2,4 @@ export type EdgeRow = { id: string; name: string; parent_id: string | null; - tag_ids: string[]; - tag_count: number; }; diff --git a/src/tag/dto/get-tags.dto.ts b/src/tag/dto/get-tags.dto.ts index ae8e5b6..6988a40 100644 --- a/src/tag/dto/get-tags.dto.ts +++ b/src/tag/dto/get-tags.dto.ts @@ -1,10 +1,6 @@ import { IsOptional, IsString } from 'class-validator'; export class GetTagsDto { - @IsOptional() - @IsString() - edge?: string; - @IsOptional() @IsString() search?: string; diff --git a/src/tag/dto/tag-response.dto.ts b/src/tag/dto/tag-response.dto.ts index d9f9f8a..3bfba5e 100644 --- a/src/tag/dto/tag-response.dto.ts +++ b/src/tag/dto/tag-response.dto.ts @@ -6,7 +6,6 @@ export type TagItemDto = { max: number | null; comment: string; unitOfMeasurement: string; - edgeIds: string[]; precision: number | null; }; diff --git a/src/tag/tag.mapper.ts b/src/tag/tag.mapper.ts index 0729650..d54e855 100644 --- a/src/tag/tag.mapper.ts +++ b/src/tag/tag.mapper.ts @@ -12,7 +12,6 @@ export function createTagResponse(rows: TagRow[]): TagResponseDto { max: row.max, comment: row.comment, unitOfMeasurement: row.unit_of_measurement, - edgeIds: row.edge_ids ?? [], precision: row.precision, })), }; diff --git a/src/tag/tag.repository.ts b/src/tag/tag.repository.ts index ea79e46..0378fc5 100644 --- a/src/tag/tag.repository.ts +++ b/src/tag/tag.repository.ts @@ -6,8 +6,8 @@ import { TagRow } from './tag.types'; export class TagRepository { constructor(private readonly db: DbService) {} - /** Читает метаданные тегов с учетом связей из tag.edge_ids и edge.tag_ids. */ - async findAll(edge: string | null, search: string | null): Promise { + /** Reads the tag catalog. */ + async findAll(search: string | null): Promise { const result = await this.db.query( ` SELECT @@ -18,31 +18,20 @@ export class TagRepository { t.max, t.comment, t.unit_of_measurement, - t.edge_ids, t.precision FROM tag AS t WHERE ( - $1::varchar IS NULL - OR $1::varchar = ANY(t.edge_ids) - OR EXISTS ( - SELECT 1 - FROM edge AS e - WHERE e.id = $1::varchar - AND t.id = ANY(e.tag_ids) - ) + $1::text IS NULL + OR t.id ILIKE $1::text + OR t.name ILIKE $1::text + OR t.comment ILIKE $1::text ) - 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 NULLIF(t.tag_group, '') ASC NULLS LAST, t.name ASC, t.id ASC `, - [edge, search], + [search], ); return result.rows; diff --git a/src/tag/tag.service.ts b/src/tag/tag.service.ts index 440d26c..0575d21 100644 --- a/src/tag/tag.service.ts +++ b/src/tag/tag.service.ts @@ -10,9 +10,8 @@ export class TagService { /** Нормализует фильтры и возвращает метаданные тегов для UI. */ async findAll(query: GetTagsDto): Promise { - const edge = query.edge ?? 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); } } diff --git a/src/tag/tag.types.ts b/src/tag/tag.types.ts index 751f007..618ae5d 100644 --- a/src/tag/tag.types.ts +++ b/src/tag/tag.types.ts @@ -6,6 +6,5 @@ export type TagRow = { max: number | null; comment: string; unit_of_measurement: string; - edge_ids: string[]; precision: number | null; }; -- 2.49.1