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:
@@ -1,5 +1,6 @@
|
||||
import { QueryResult, DatabaseConfig } from '../types';
|
||||
import { QueryResult, DatabaseConfig, Environment } from '../types';
|
||||
import { mainPool } from '../config/database';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
|
||||
interface AqlRequestConfig {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
@@ -73,19 +74,30 @@ export class AqlExecutor {
|
||||
*/
|
||||
async executeAqlQuery(
|
||||
databaseId: string,
|
||||
config: AqlRequestConfig
|
||||
config: AqlRequestConfig,
|
||||
environment: Environment = 'prod'
|
||||
): Promise<QueryResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Получаем конфигурацию БД
|
||||
const dbConfig = await this.getDatabaseConfig(databaseId);
|
||||
const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId);
|
||||
|
||||
if (!dbConfig) {
|
||||
throw new Error(`AQL database with id ${databaseId} not found or not configured`);
|
||||
}
|
||||
|
||||
if (!dbConfig.aql_base_url) {
|
||||
// Use test credentials when environment is test and test env is configured
|
||||
let baseUrl = dbConfig.aql_base_url;
|
||||
let authValue = dbConfig.aql_auth_value;
|
||||
let headers_config = dbConfig.aql_headers;
|
||||
|
||||
if (environment === 'test' && dbConfig.has_test_env) {
|
||||
if (dbConfig.test_aql_base_url) baseUrl = dbConfig.test_aql_base_url;
|
||||
if (dbConfig.test_aql_auth_value) authValue = dbConfig.test_aql_auth_value;
|
||||
if (dbConfig.test_aql_headers) headers_config = dbConfig.test_aql_headers;
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error(`AQL base URL not configured for database ${databaseId}`);
|
||||
}
|
||||
|
||||
@@ -100,7 +112,7 @@ export class AqlExecutor {
|
||||
: '';
|
||||
|
||||
// Формируем полный URL
|
||||
const fullUrl = `${dbConfig.aql_base_url}${processedEndpoint}${queryString}`;
|
||||
const fullUrl = `${baseUrl}${processedEndpoint}${queryString}`;
|
||||
|
||||
// Обрабатываем body с параметрами
|
||||
let processedBody: string | undefined;
|
||||
@@ -115,19 +127,19 @@ export class AqlExecutor {
|
||||
};
|
||||
|
||||
// Добавляем аутентификацию
|
||||
if (dbConfig.aql_auth_type === 'basic' && dbConfig.aql_auth_value) {
|
||||
headers['Authorization'] = `Basic ${dbConfig.aql_auth_value}`;
|
||||
} else if (dbConfig.aql_auth_type === 'bearer' && dbConfig.aql_auth_value) {
|
||||
headers['Authorization'] = `Bearer ${dbConfig.aql_auth_value}`;
|
||||
} else if (dbConfig.aql_auth_type === 'custom' && dbConfig.aql_auth_value) {
|
||||
headers['Authorization'] = dbConfig.aql_auth_value;
|
||||
if (dbConfig.aql_auth_type === 'basic' && authValue) {
|
||||
headers['Authorization'] = `Basic ${authValue}`;
|
||||
} else if (dbConfig.aql_auth_type === 'bearer' && authValue) {
|
||||
headers['Authorization'] = `Bearer ${authValue}`;
|
||||
} else if (dbConfig.aql_auth_type === 'custom' && authValue) {
|
||||
headers['Authorization'] = authValue;
|
||||
}
|
||||
|
||||
// Добавляем кастомные заголовки из конфигурации БД
|
||||
if (dbConfig.aql_headers) {
|
||||
const customHeaders = typeof dbConfig.aql_headers === 'string'
|
||||
? JSON.parse(dbConfig.aql_headers)
|
||||
: dbConfig.aql_headers;
|
||||
if (headers_config) {
|
||||
const customHeaders = typeof headers_config === 'string'
|
||||
? JSON.parse(headers_config)
|
||||
: headers_config;
|
||||
|
||||
Object.assign(headers, customHeaders);
|
||||
}
|
||||
@@ -234,28 +246,29 @@ export class AqlExecutor {
|
||||
/**
|
||||
* Тестирует подключение к AQL базе
|
||||
*/
|
||||
async testConnection(databaseId: string): Promise<{ success: boolean; error?: string }> {
|
||||
async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const dbConfig = await this.getDatabaseConfig(databaseId);
|
||||
const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId);
|
||||
|
||||
if (!dbConfig) {
|
||||
return { success: false, error: 'Database not found' };
|
||||
}
|
||||
|
||||
if (!dbConfig.aql_base_url) {
|
||||
let testUrl = dbConfig.aql_base_url;
|
||||
if (environment === 'test' && dbConfig.has_test_env && dbConfig.test_aql_base_url) {
|
||||
testUrl = dbConfig.test_aql_base_url;
|
||||
}
|
||||
|
||||
if (!testUrl) {
|
||||
return { success: false, error: 'AQL base URL not configured' };
|
||||
}
|
||||
|
||||
// Пробуем выполнить простой запрос для проверки соединения
|
||||
const response = await fetch(dbConfig.aql_base_url, {
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 404) {
|
||||
// 404 тоже OK - это значит что сервер доступен
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: `HTTP ${response.status}` };
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Pool } from 'pg';
|
||||
import { DatabaseConfig } from '../types';
|
||||
import { DatabaseConfig, Environment } from '../types';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
interface PoolEntry {
|
||||
prod: Pool;
|
||||
test?: Pool;
|
||||
}
|
||||
|
||||
class DatabasePoolManager {
|
||||
private pools: Map<string, Pool> = new Map();
|
||||
private pools: Map<string, PoolEntry> = new Map();
|
||||
|
||||
async initialize() {
|
||||
// Load databases from DB instead of env
|
||||
await this.loadDatabasesFromDB();
|
||||
}
|
||||
|
||||
@@ -17,67 +21,112 @@ class DatabasePoolManager {
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
const dbConfig: DatabaseConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
database_name: row.database_name,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
ssl: row.ssl,
|
||||
is_active: row.is_active,
|
||||
};
|
||||
|
||||
const dbConfig = this.rowToConfig(row);
|
||||
this.addPool(dbConfig);
|
||||
}
|
||||
|
||||
console.log(`✅ Loaded ${result.rows.length} database connection(s) from DB`);
|
||||
console.log(`Loaded ${result.rows.length} database connection(s) from DB`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load databases from DB:', error);
|
||||
console.error('Failed to load databases from DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private rowToConfig(row: any): DatabaseConfig {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
database_name: row.database_name,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
ssl: row.ssl,
|
||||
is_active: row.is_active,
|
||||
aql_base_url: row.aql_base_url,
|
||||
aql_auth_type: row.aql_auth_type,
|
||||
aql_auth_value: row.aql_auth_value,
|
||||
aql_headers: row.aql_headers,
|
||||
has_test_env: row.has_test_env,
|
||||
test_host: row.test_host,
|
||||
test_port: row.test_port,
|
||||
test_database_name: row.test_database_name,
|
||||
test_username: row.test_username,
|
||||
test_password: row.test_password,
|
||||
test_ssl: row.test_ssl,
|
||||
test_aql_base_url: row.test_aql_base_url,
|
||||
test_aql_auth_value: row.test_aql_auth_value,
|
||||
test_aql_headers: row.test_aql_headers,
|
||||
};
|
||||
}
|
||||
|
||||
private createPool(host: string, port: number, database: string, user: string, password: string, ssl: boolean): Pool {
|
||||
const pool = new Pool({
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
ssl: ssl ? { rejectUnauthorized: false } : false,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 60000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`Database pool error:`, err);
|
||||
});
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
addPool(dbConfig: DatabaseConfig) {
|
||||
if (this.pools.has(dbConfig.id)) {
|
||||
console.warn(`Pool with id ${dbConfig.id} already exists. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
database: dbConfig.database_name,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
ssl: dbConfig.ssl ? { rejectUnauthorized: false } : false,
|
||||
max: 20, // Увеличено количество соединений
|
||||
idleTimeoutMillis: 60000, // 60 секунд
|
||||
connectionTimeoutMillis: 10000, // 10 секунд (было 2 секунды)
|
||||
keepAlive: true, // Поддерживать соединения активными
|
||||
keepAliveInitialDelayMillis: 10000, // Начать keepAlive через 10 секунд
|
||||
});
|
||||
const prodPool = this.createPool(
|
||||
dbConfig.host, dbConfig.port, dbConfig.database_name,
|
||||
dbConfig.username, dbConfig.password, dbConfig.ssl
|
||||
);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`Database pool error for ${dbConfig.id}:`, err);
|
||||
});
|
||||
const entry: PoolEntry = { prod: prodPool };
|
||||
|
||||
this.pools.set(dbConfig.id, pool);
|
||||
console.log(`✅ Pool created for database: ${dbConfig.name} (${dbConfig.id})`);
|
||||
if (dbConfig.has_test_env && dbConfig.test_host) {
|
||||
entry.test = this.createPool(
|
||||
dbConfig.test_host,
|
||||
dbConfig.test_port || dbConfig.port,
|
||||
dbConfig.test_database_name || dbConfig.database_name,
|
||||
dbConfig.test_username || dbConfig.username,
|
||||
dbConfig.test_password || dbConfig.password,
|
||||
dbConfig.test_ssl !== undefined ? dbConfig.test_ssl : dbConfig.ssl
|
||||
);
|
||||
console.log(`Pool created for database: ${dbConfig.name} (${dbConfig.id}) [prod + test]`);
|
||||
} else {
|
||||
console.log(`Pool created for database: ${dbConfig.name} (${dbConfig.id}) [prod]`);
|
||||
}
|
||||
|
||||
this.pools.set(dbConfig.id, entry);
|
||||
}
|
||||
|
||||
removePool(databaseId: string) {
|
||||
const pool = this.pools.get(databaseId);
|
||||
if (pool) {
|
||||
pool.end();
|
||||
const entry = this.pools.get(databaseId);
|
||||
if (entry) {
|
||||
entry.prod.end();
|
||||
if (entry.test) entry.test.end();
|
||||
this.pools.delete(databaseId);
|
||||
console.log(`Pool removed for database: ${databaseId}`);
|
||||
}
|
||||
}
|
||||
|
||||
getPool(databaseId: string): Pool | undefined {
|
||||
return this.pools.get(databaseId);
|
||||
getPool(databaseId: string, environment: Environment = 'prod'): Pool | undefined {
|
||||
const entry = this.pools.get(databaseId);
|
||||
if (!entry) return undefined;
|
||||
if (environment === 'test' && entry.test) return entry.test;
|
||||
return entry.prod;
|
||||
}
|
||||
|
||||
async getDatabaseConfig(databaseId: string): Promise<DatabaseConfig | null> {
|
||||
@@ -86,26 +135,8 @@ class DatabasePoolManager {
|
||||
'SELECT * FROM databases WHERE id = $1',
|
||||
[databaseId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
database_name: row.database_name,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
ssl: row.ssl,
|
||||
is_active: row.is_active,
|
||||
aql_base_url: row.aql_base_url,
|
||||
aql_auth_type: row.aql_auth_type,
|
||||
aql_auth_value: row.aql_auth_value,
|
||||
aql_headers: row.aql_headers,
|
||||
};
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.rowToConfig(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching database config:', error);
|
||||
return null;
|
||||
@@ -115,7 +146,7 @@ class DatabasePoolManager {
|
||||
async getAllDatabaseConfigs(): Promise<DatabaseConfig[]> {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, name, type, host, port, database_name, is_active FROM databases WHERE is_active = true'
|
||||
'SELECT id, name, type, host, port, database_name, is_active, has_test_env FROM databases WHERE is_active = true'
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
@@ -124,53 +155,40 @@ class DatabasePoolManager {
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(databaseId: string): Promise<boolean> {
|
||||
const pool = this.getPool(databaseId);
|
||||
if (!pool) {
|
||||
return false;
|
||||
}
|
||||
async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<boolean> {
|
||||
const pool = this.getPool(databaseId, environment);
|
||||
if (!pool) return false;
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Connection test failed for ${databaseId}:`, error);
|
||||
console.error(`Connection test failed for ${databaseId} (${environment}):`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadPool(databaseId: string) {
|
||||
// Remove old pool
|
||||
this.removePool(databaseId);
|
||||
|
||||
// Load new config from DB
|
||||
const result = await mainPool.query(
|
||||
'SELECT * FROM databases WHERE id = $1 AND is_active = true',
|
||||
[databaseId]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0];
|
||||
const dbConfig: DatabaseConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
database_name: row.database_name,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
ssl: row.ssl,
|
||||
is_active: row.is_active,
|
||||
};
|
||||
|
||||
const dbConfig = this.rowToConfig(result.rows[0]);
|
||||
this.addPool(dbConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
const promises = Array.from(this.pools.values()).map((pool) => pool.end());
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const entry of this.pools.values()) {
|
||||
promises.push(entry.prod.end());
|
||||
if (entry.test) promises.push(entry.test.end());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
this.pools.clear();
|
||||
console.log('All database pools closed');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vm from 'vm';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { aqlExecutor } from './AqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError, Environment } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
|
||||
interface IsolatedScriptContext {
|
||||
@@ -9,6 +9,7 @@ interface IsolatedScriptContext {
|
||||
scriptQueries: ScriptQuery[];
|
||||
requestParams: Record<string, any>;
|
||||
endpointParameters: EndpointParameter[];
|
||||
environment?: Environment;
|
||||
}
|
||||
|
||||
export class IsolatedScriptExecutor {
|
||||
@@ -74,7 +75,7 @@ export class IsolatedScriptExecutor {
|
||||
body: query.aql_body || '',
|
||||
queryParams: query.aql_query_params || {},
|
||||
parameters: allParams,
|
||||
});
|
||||
}, context.environment);
|
||||
|
||||
queries.push({
|
||||
name: queryName,
|
||||
@@ -118,7 +119,7 @@ export class IsolatedScriptExecutor {
|
||||
paramValues.push(value !== undefined ? value : null);
|
||||
});
|
||||
|
||||
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues);
|
||||
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues, context.environment);
|
||||
|
||||
queries.push({
|
||||
name: queryName,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { aqlExecutor } from './AqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError, Environment } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ScriptContext {
|
||||
scriptQueries: ScriptQuery[];
|
||||
requestParams: Record<string, any>;
|
||||
endpointParameters: EndpointParameter[];
|
||||
environment?: Environment;
|
||||
}
|
||||
|
||||
export class ScriptExecutor {
|
||||
@@ -135,7 +136,7 @@ print(json.dumps(result))
|
||||
body: query.aql_body || '',
|
||||
queryParams: query.aql_query_params || {},
|
||||
parameters: allParams,
|
||||
});
|
||||
}, context.environment);
|
||||
|
||||
queries.push({
|
||||
name: request.query_name,
|
||||
@@ -195,7 +196,8 @@ print(json.dumps(result))
|
||||
const result = await sqlExecutor.executeQuery(
|
||||
dbId,
|
||||
processedQuery,
|
||||
paramValues
|
||||
paramValues,
|
||||
context.environment
|
||||
);
|
||||
|
||||
queries.push({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { QueryResult } from '../types';
|
||||
import { QueryResult, Environment } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
|
||||
export class SqlExecutor {
|
||||
@@ -32,9 +32,10 @@ export class SqlExecutor {
|
||||
async executeQuery(
|
||||
databaseId: string,
|
||||
sqlQuery: string,
|
||||
params: any[] = []
|
||||
params: any[] = [],
|
||||
environment: Environment = 'prod'
|
||||
): Promise<QueryResult> {
|
||||
const pool = databasePoolManager.getPool(databaseId);
|
||||
const pool = databasePoolManager.getPool(databaseId, environment);
|
||||
|
||||
if (!pool) {
|
||||
throw new Error(`Database with id ${databaseId} not found or not configured`);
|
||||
|
||||
306
backend/src/services/VersionService.ts
Normal file
306
backend/src/services/VersionService.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { mainPool } from '../config/database';
|
||||
import { EndpointVersion, VersionStatus } from '../types';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const SNAPSHOT_FIELDS = [
|
||||
'name', 'description', 'method', 'path', 'database_id', 'sql_query',
|
||||
'parameters', 'execution_type', 'script_language', 'script_code',
|
||||
'script_queries', 'aql_method', 'aql_endpoint', 'aql_body',
|
||||
'aql_query_params', 'is_public', 'enable_logging', 'detailed_response',
|
||||
'response_schema'
|
||||
] as const;
|
||||
|
||||
function computeHash(data: Record<string, any>): string {
|
||||
const relevant: Record<string, any> = {};
|
||||
for (const key of SNAPSHOT_FIELDS) {
|
||||
relevant[key] = data[key] ?? null;
|
||||
}
|
||||
return crypto.createHash('sha256').update(JSON.stringify(relevant)).digest('hex');
|
||||
}
|
||||
|
||||
class VersionService {
|
||||
async createVersionFromEndpoint(
|
||||
endpointId: string,
|
||||
userId: string,
|
||||
changeMessage?: string,
|
||||
status: VersionStatus = 'published'
|
||||
): Promise<EndpointVersion> {
|
||||
const epResult = await mainPool.query('SELECT * FROM endpoints WHERE id = $1', [endpointId]);
|
||||
if (epResult.rows.length === 0) throw new Error('Endpoint not found');
|
||||
const ep = epResult.rows[0];
|
||||
|
||||
const nextVersion = (ep.current_version || 0) + 1;
|
||||
const hash = computeHash(ep);
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO endpoint_versions (
|
||||
endpoint_id, version_number, name, description, method, path,
|
||||
database_id, sql_query, parameters, execution_type, script_language,
|
||||
script_code, script_queries, aql_method, aql_endpoint, aql_body,
|
||||
aql_query_params, is_public, enable_logging, detailed_response,
|
||||
response_schema, status, change_message, user_id, content_hash
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
|
||||
RETURNING *`,
|
||||
[
|
||||
endpointId, nextVersion, ep.name, ep.description, ep.method, ep.path,
|
||||
ep.database_id, ep.sql_query, JSON.stringify(ep.parameters || []),
|
||||
ep.execution_type, ep.script_language, ep.script_code,
|
||||
JSON.stringify(ep.script_queries || []), ep.aql_method, ep.aql_endpoint,
|
||||
ep.aql_body, JSON.stringify(ep.aql_query_params || {}),
|
||||
ep.is_public, ep.enable_logging, ep.detailed_response,
|
||||
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
|
||||
status, changeMessage || null, userId, hash
|
||||
]
|
||||
);
|
||||
|
||||
const version = result.rows[0];
|
||||
|
||||
const updateFields: string[] = ['current_version = $1'];
|
||||
const updateParams: any[] = [nextVersion];
|
||||
let idx = 2;
|
||||
|
||||
if (status === 'published') {
|
||||
// Archive previous published
|
||||
await mainPool.query(
|
||||
`UPDATE endpoint_versions SET status = 'archived'
|
||||
WHERE endpoint_id = $1 AND status = 'published' AND id != $2`,
|
||||
[endpointId, version.id]
|
||||
);
|
||||
updateFields.push(`published_version_id = $${idx}`);
|
||||
updateParams.push(version.id);
|
||||
idx++;
|
||||
} else if (status === 'draft') {
|
||||
updateFields.push(`draft_version_id = $${idx}`);
|
||||
updateParams.push(version.id);
|
||||
idx++;
|
||||
}
|
||||
|
||||
updateParams.push(endpointId);
|
||||
await mainPool.query(
|
||||
`UPDATE endpoints SET ${updateFields.join(', ')} WHERE id = $${idx}`,
|
||||
updateParams
|
||||
);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
async saveDraft(
|
||||
endpointId: string,
|
||||
data: Record<string, any>,
|
||||
userId: string,
|
||||
changeMessage?: string
|
||||
): Promise<EndpointVersion> {
|
||||
const epResult = await mainPool.query(
|
||||
'SELECT current_version, draft_version_id FROM endpoints WHERE id = $1',
|
||||
[endpointId]
|
||||
);
|
||||
if (epResult.rows.length === 0) throw new Error('Endpoint not found');
|
||||
|
||||
const ep = epResult.rows[0];
|
||||
const nextVersion = (ep.current_version || 0) + 1;
|
||||
const hash = computeHash(data);
|
||||
|
||||
// If there's an existing draft, delete it
|
||||
if (ep.draft_version_id) {
|
||||
await mainPool.query(
|
||||
'DELETE FROM endpoint_versions WHERE id = $1 AND status = $2',
|
||||
[ep.draft_version_id, 'draft']
|
||||
);
|
||||
}
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO endpoint_versions (
|
||||
endpoint_id, version_number, name, description, method, path,
|
||||
database_id, sql_query, parameters, execution_type, script_language,
|
||||
script_code, script_queries, aql_method, aql_endpoint, aql_body,
|
||||
aql_query_params, is_public, enable_logging, detailed_response,
|
||||
response_schema, status, change_message, user_id, content_hash
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
|
||||
RETURNING *`,
|
||||
[
|
||||
endpointId, nextVersion,
|
||||
data.name, data.description || null, data.method, data.path,
|
||||
data.database_id || null, data.sql_query || null,
|
||||
JSON.stringify(data.parameters || []),
|
||||
data.execution_type || 'sql', data.script_language || null,
|
||||
data.script_code || null, JSON.stringify(data.script_queries || []),
|
||||
data.aql_method || null, data.aql_endpoint || null,
|
||||
data.aql_body || null, JSON.stringify(data.aql_query_params || {}),
|
||||
data.is_public || false, data.enable_logging || false,
|
||||
data.detailed_response || false,
|
||||
data.response_schema ? JSON.stringify(data.response_schema) : null,
|
||||
'draft', changeMessage || null, userId, hash
|
||||
]
|
||||
);
|
||||
|
||||
const version = result.rows[0];
|
||||
|
||||
await mainPool.query(
|
||||
'UPDATE endpoints SET current_version = $1, draft_version_id = $2 WHERE id = $3',
|
||||
[nextVersion, version.id, endpointId]
|
||||
);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
async publishVersion(versionId: string, userId: string): Promise<void> {
|
||||
const vResult = await mainPool.query(
|
||||
'SELECT * FROM endpoint_versions WHERE id = $1',
|
||||
[versionId]
|
||||
);
|
||||
if (vResult.rows.length === 0) throw new Error('Version not found');
|
||||
const version = vResult.rows[0];
|
||||
|
||||
// Archive previous published versions
|
||||
await mainPool.query(
|
||||
`UPDATE endpoint_versions SET status = 'archived'
|
||||
WHERE endpoint_id = $1 AND status = 'published'`,
|
||||
[version.endpoint_id]
|
||||
);
|
||||
|
||||
// Mark this version as published
|
||||
await mainPool.query(
|
||||
`UPDATE endpoint_versions SET status = 'published' WHERE id = $1`,
|
||||
[versionId]
|
||||
);
|
||||
|
||||
// Copy version content to endpoints table
|
||||
await mainPool.query(
|
||||
`UPDATE endpoints SET
|
||||
name = $1, description = $2, method = $3, path = $4,
|
||||
database_id = $5, sql_query = $6, parameters = $7,
|
||||
execution_type = $8, script_language = $9, script_code = $10,
|
||||
script_queries = $11, aql_method = $12, aql_endpoint = $13,
|
||||
aql_body = $14, aql_query_params = $15, is_public = $16,
|
||||
enable_logging = $17, detailed_response = $18, response_schema = $19,
|
||||
published_version_id = $20, draft_version_id = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $21`,
|
||||
[
|
||||
version.name, version.description, version.method, version.path,
|
||||
version.database_id, version.sql_query, JSON.stringify(version.parameters || []),
|
||||
version.execution_type, version.script_language, version.script_code,
|
||||
JSON.stringify(version.script_queries || []), version.aql_method,
|
||||
version.aql_endpoint, version.aql_body,
|
||||
JSON.stringify(version.aql_query_params || {}),
|
||||
version.is_public, version.enable_logging, version.detailed_response,
|
||||
version.response_schema ? JSON.stringify(version.response_schema) : null,
|
||||
versionId, version.endpoint_id
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async rollbackToVersion(
|
||||
endpointId: string,
|
||||
versionId: string,
|
||||
userId: string
|
||||
): Promise<EndpointVersion> {
|
||||
const vResult = await mainPool.query(
|
||||
'SELECT * FROM endpoint_versions WHERE id = $1 AND endpoint_id = $2',
|
||||
[versionId, endpointId]
|
||||
);
|
||||
if (vResult.rows.length === 0) throw new Error('Version not found');
|
||||
const oldVersion = vResult.rows[0];
|
||||
|
||||
const epResult = await mainPool.query(
|
||||
'SELECT current_version FROM endpoints WHERE id = $1',
|
||||
[endpointId]
|
||||
);
|
||||
const nextVersionNum = (epResult.rows[0]?.current_version || 0) + 1;
|
||||
|
||||
// Archive current published
|
||||
await mainPool.query(
|
||||
`UPDATE endpoint_versions SET status = 'archived'
|
||||
WHERE endpoint_id = $1 AND status = 'published'`,
|
||||
[endpointId]
|
||||
);
|
||||
|
||||
// Create new version from old snapshot
|
||||
const hash = computeHash(oldVersion);
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO endpoint_versions (
|
||||
endpoint_id, version_number, name, description, method, path,
|
||||
database_id, sql_query, parameters, execution_type, script_language,
|
||||
script_code, script_queries, aql_method, aql_endpoint, aql_body,
|
||||
aql_query_params, is_public, enable_logging, detailed_response,
|
||||
response_schema, status, change_message, user_id, content_hash
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
|
||||
RETURNING *`,
|
||||
[
|
||||
endpointId, nextVersionNum, oldVersion.name, oldVersion.description,
|
||||
oldVersion.method, oldVersion.path, oldVersion.database_id,
|
||||
oldVersion.sql_query, JSON.stringify(oldVersion.parameters || []),
|
||||
oldVersion.execution_type, oldVersion.script_language,
|
||||
oldVersion.script_code, JSON.stringify(oldVersion.script_queries || []),
|
||||
oldVersion.aql_method, oldVersion.aql_endpoint, oldVersion.aql_body,
|
||||
JSON.stringify(oldVersion.aql_query_params || {}),
|
||||
oldVersion.is_public, oldVersion.enable_logging, oldVersion.detailed_response,
|
||||
oldVersion.response_schema ? JSON.stringify(oldVersion.response_schema) : null,
|
||||
'published', `Rollback to v${oldVersion.version_number}`,
|
||||
userId, hash
|
||||
]
|
||||
);
|
||||
|
||||
const newVersion = result.rows[0];
|
||||
|
||||
// Update endpoints table with rolled-back content
|
||||
await mainPool.query(
|
||||
`UPDATE endpoints SET
|
||||
name = $1, description = $2, method = $3, path = $4,
|
||||
database_id = $5, sql_query = $6, parameters = $7,
|
||||
execution_type = $8, script_language = $9, script_code = $10,
|
||||
script_queries = $11, aql_method = $12, aql_endpoint = $13,
|
||||
aql_body = $14, aql_query_params = $15, is_public = $16,
|
||||
enable_logging = $17, detailed_response = $18, response_schema = $19,
|
||||
published_version_id = $20, draft_version_id = NULL,
|
||||
current_version = $21, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $22`,
|
||||
[
|
||||
oldVersion.name, oldVersion.description, oldVersion.method, oldVersion.path,
|
||||
oldVersion.database_id, oldVersion.sql_query,
|
||||
JSON.stringify(oldVersion.parameters || []),
|
||||
oldVersion.execution_type, oldVersion.script_language,
|
||||
oldVersion.script_code, JSON.stringify(oldVersion.script_queries || []),
|
||||
oldVersion.aql_method, oldVersion.aql_endpoint, oldVersion.aql_body,
|
||||
JSON.stringify(oldVersion.aql_query_params || {}),
|
||||
oldVersion.is_public, oldVersion.enable_logging, oldVersion.detailed_response,
|
||||
oldVersion.response_schema ? JSON.stringify(oldVersion.response_schema) : null,
|
||||
newVersion.id, nextVersionNum, endpointId
|
||||
]
|
||||
);
|
||||
|
||||
return newVersion;
|
||||
}
|
||||
|
||||
async getVersionHistory(endpointId: string): Promise<EndpointVersion[]> {
|
||||
const result = await mainPool.query(
|
||||
`SELECT ev.*, u.username as user_name
|
||||
FROM endpoint_versions ev
|
||||
LEFT JOIN users u ON ev.user_id = u.id
|
||||
WHERE ev.endpoint_id = $1
|
||||
ORDER BY ev.version_number DESC`,
|
||||
[endpointId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getVersion(versionId: string): Promise<EndpointVersion | null> {
|
||||
const result = await mainPool.query(
|
||||
'SELECT * FROM endpoint_versions WHERE id = $1',
|
||||
[versionId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async getDraft(endpointId: string): Promise<EndpointVersion | null> {
|
||||
const result = await mainPool.query(
|
||||
`SELECT * FROM endpoint_versions
|
||||
WHERE endpoint_id = $1 AND status = 'draft'
|
||||
ORDER BY version_number DESC LIMIT 1`,
|
||||
[endpointId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
export const versionService = new VersionService();
|
||||
Reference in New Issue
Block a user