Add test/prod environments and endpoint versioning

Phase 1: Test/Prod Database Configurations
- Migration 010: test_* columns on databases table, environment column on request_logs
- DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback
- SqlExecutor, ScriptExecutor, IsolatedScriptExecutor, AqlExecutor: environment param threaded through all execution paths
- dynamicApiController: X-Environment header detection, environment in logging
- databaseManagementController: CRUD for test credentials, testConnection with ?env=test
- Frontend: test env form in DatabaseModal, env toggle in EndpointEditor test panel

Phase 2: Endpoint Versioning
- Migration 011: endpoint_versions table with full snapshots, backfill v1 for existing endpoints
- VersionService: createVersion, saveDraft, publishVersion, rollbackToVersion, getHistory
- endpointController: auto-versioning on update, 6 new version management handlers
- dynamicApiController: draft serving when environment=test and draft exists
- Frontend: Save Draft button, version history panel with publish/rollback actions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:04:11 +03:00
parent c918f34595
commit 801d0cce5f
19 changed files with 1263 additions and 223 deletions

View File

@@ -3,13 +3,15 @@ import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { databasePoolManager } from '../services/DatabasePoolManager';
import { generateSchemaForDatabase } from './schemaController';
import { Environment } from '../types';
// Только админы могут управлять базами данных
export const getDatabases = async (req: AuthRequest, res: Response) => {
try {
const result = await mainPool.query(
`SELECT id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_ssl,
test_aql_base_url, test_aql_headers,
created_at, updated_at
FROM databases ORDER BY name`
);
@@ -28,6 +30,8 @@ export const getDatabase = async (req: AuthRequest, res: Response) => {
const result = await mainPool.query(
`SELECT id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_ssl,
test_aql_base_url, test_aql_headers,
created_at, updated_at
FROM databases WHERE id = $1`,
[id]
@@ -48,18 +52,18 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
try {
const {
name, type, host, port, database_name, username, password, ssl,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl,
test_aql_base_url, test_aql_auth_value, test_aql_headers
} = req.body;
const dbType = type || 'postgresql';
// Валидация для обычных БД
if (dbType !== 'aql') {
if (!name || !host || !port || !database_name || !username || !password) {
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
}
} else {
// Валидация для AQL
if (!name || !aql_base_url) {
return res.status(400).json({ error: 'Не заполнены обязательные поля для AQL базы' });
}
@@ -68,9 +72,11 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
const result = await mainPool.query(
`INSERT INTO databases (
name, type, host, port, database_name, username, password, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl,
test_aql_base_url, test_aql_auth_value, test_aql_headers
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $12)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
name,
@@ -84,17 +90,25 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
aql_base_url || null,
aql_auth_type || null,
aql_auth_value || null,
aql_headers ? JSON.stringify(aql_headers) : null
aql_headers ? JSON.stringify(aql_headers) : null,
has_test_env || false,
test_host || null,
test_port || null,
test_database_name || null,
test_username || null,
test_password || null,
test_ssl || null,
test_aql_base_url || null,
test_aql_auth_value || null,
test_aql_headers ? JSON.stringify(test_aql_headers) : null,
]
);
const newDb = result.rows[0];
// Добавить пул подключений (только для не-AQL баз)
if (dbType !== 'aql') {
await databasePoolManager.reloadPool(newDb.id);
// Generate schema in background for PostgreSQL databases
if (dbType === 'postgresql') {
generateSchemaForDatabase(newDb.id).catch(err => {
console.error('Background schema generation failed:', err.message);
@@ -102,9 +116,10 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
}
}
// Не возвращаем пароль
delete newDb.password;
delete newDb.aql_auth_value; // Также не возвращаем auth value
delete newDb.aql_auth_value;
delete newDb.test_password;
delete newDb.test_aql_auth_value;
res.status(201).json(newDb);
} catch (error: any) {
@@ -121,62 +136,67 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => {
const { id } = req.params;
const {
name, type, host, port, database_name, username, password, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl,
test_aql_base_url, test_aql_auth_value, test_aql_headers
} = req.body;
// Если пароль/auth не передан, не обновляем его
let query;
let params;
// Build SET clauses and params dynamically to handle optional password fields
const setClauses: string[] = [];
const params: any[] = [];
let idx = 1;
if (password || aql_auth_value) {
query = `
UPDATE databases
SET name = COALESCE($1, name),
type = COALESCE($2, type),
host = COALESCE($3, host),
port = COALESCE($4, port),
database_name = COALESCE($5, database_name),
username = COALESCE($6, username),
password = COALESCE($7, password),
ssl = COALESCE($8, ssl),
is_active = COALESCE($9, is_active),
aql_base_url = COALESCE($10, aql_base_url),
aql_auth_type = COALESCE($11, aql_auth_type),
aql_auth_value = COALESCE($12, aql_auth_value),
aql_headers = COALESCE($13, aql_headers),
updated_at = CURRENT_TIMESTAMP
WHERE id = $14
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers, created_at, updated_at
`;
params = [
name, type, host, port, database_name, username, password || aql_auth_value, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers ? JSON.stringify(aql_headers) : null, id
];
} else {
query = `
UPDATE databases
SET name = COALESCE($1, name),
type = COALESCE($2, type),
host = COALESCE($3, host),
port = COALESCE($4, port),
database_name = COALESCE($5, database_name),
username = COALESCE($6, username),
ssl = COALESCE($7, ssl),
is_active = COALESCE($8, is_active),
aql_base_url = COALESCE($9, aql_base_url),
aql_auth_type = COALESCE($10, aql_auth_type),
aql_headers = COALESCE($11, aql_headers),
updated_at = CURRENT_TIMESTAMP
WHERE id = $12
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers, created_at, updated_at
`;
params = [
name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers ? JSON.stringify(aql_headers) : null, id
];
}
const addField = (clause: string, value: any) => {
setClauses.push(`${clause} = $${idx}`);
params.push(value);
idx++;
};
const addCoalesce = (field: string, value: any) => {
setClauses.push(`${field} = COALESCE($${idx}, ${field})`);
params.push(value);
idx++;
};
addCoalesce('name', name);
addCoalesce('type', type);
addCoalesce('host', host);
addCoalesce('port', port);
addCoalesce('database_name', database_name);
addCoalesce('username', username);
if (password) { addField('password', password); }
addCoalesce('ssl', ssl);
addCoalesce('is_active', is_active);
addCoalesce('aql_base_url', aql_base_url);
addCoalesce('aql_auth_type', aql_auth_type);
if (aql_auth_value) { addField('aql_auth_value', aql_auth_value); }
addCoalesce('aql_headers', aql_headers ? JSON.stringify(aql_headers) : null);
addCoalesce('has_test_env', has_test_env);
// Test fields: use direct SET (nullable — user may want to clear them)
addField('test_host', test_host || null);
addField('test_port', test_port || null);
addField('test_database_name', test_database_name || null);
addField('test_username', test_username || null);
if (test_password) { addField('test_password', test_password); }
addField('test_ssl', test_ssl !== undefined ? test_ssl : null);
addField('test_aql_base_url', test_aql_base_url || null);
if (test_aql_auth_value) { addField('test_aql_auth_value', test_aql_auth_value); }
addField('test_aql_headers', test_aql_headers ? JSON.stringify(test_aql_headers) : null);
setClauses.push('updated_at = CURRENT_TIMESTAMP');
params.push(id);
const whereIdx = idx;
const query = `
UPDATE databases
SET ${setClauses.join(', ')}
WHERE id = $${whereIdx}
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_ssl,
test_aql_base_url, test_aql_headers,
created_at, updated_at`;
const result = await mainPool.query(query, params);
@@ -184,7 +204,6 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ error: 'База данных не найдена' });
}
// Перезагрузить пул (только для не-AQL баз)
if (result.rows[0].type !== 'aql') {
await databasePoolManager.reloadPool(id);
}
@@ -203,7 +222,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
// Проверяем, используется ли база данных в эндпоинтах
const endpointCheck = await mainPool.query(
'SELECT COUNT(*) FROM endpoints WHERE database_id = $1',
[id]
@@ -224,7 +242,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ error: 'База данных не найдена' });
}
// Удалить пул
databasePoolManager.removePool(id);
res.json({ message: 'База данных удалена успешно' });
@@ -237,8 +254,8 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
export const testDatabaseConnection = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const env = (req.query.env as string)?.toLowerCase() === 'test' ? 'test' : 'prod' as Environment;
// Получаем тип БД
const dbResult = await mainPool.query('SELECT type FROM databases WHERE id = $1', [id]);
if (dbResult.rows.length === 0) {
@@ -248,17 +265,15 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) =>
const dbType = dbResult.rows[0].type;
if (dbType === 'aql') {
// Для AQL используем aqlExecutor
const { aqlExecutor } = require('../services/AqlExecutor');
const result = await aqlExecutor.testConnection(id);
const result = await aqlExecutor.testConnection(id, env);
res.json({
success: result.success,
message: result.success ? 'Подключение успешно' : result.error || 'Ошибка подключения',
});
} else {
// Для обычных БД используем databasePoolManager
const isConnected = await databasePoolManager.testConnection(id);
const isConnected = await databasePoolManager.testConnection(id, env);
res.json({
success: isConnected,