Миграции: _migrations + applyMigrations + sql/migrations/

APP_ENV: test/production с жёлтым баннером в UI
Config: версия 0.1.12
This commit is contained in:
2026-07-08 07:11:16 +04:00
parent 7982a82286
commit 591a4c3216
12 changed files with 151 additions and 35 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ function createAdminRouter({ generateCsrfToken } = {}) {
try {
const companies = await q.getAllCompanies();
res.render('v2/admin', {
companies, user: req.adminUser, version: config.version,
companies, user: req.adminUser, version: config.version, env: config.env,
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
});
} catch (e) {
@@ -52,7 +52,7 @@ function createAdminRouter({ generateCsrfToken } = {}) {
res.render('v2/admin-audit', {
audit: result.rows, total: result.total,
page, pages: Math.ceil(result.total / perPage) || 1,
days, company, version: config.version,
days, company, version: config.version, env: config.env,
});
} catch (e) {
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
+6 -1
View File
@@ -8,7 +8,12 @@
// ═══════════════════════════════════════════════════════════════════════════════
module.exports = {
version: '0.1.11',
version: '0.1.12',
// ── Среда ─────────────────────────────────────────────────────────────────
// APP_ENV=test → тестовый стенд (жёлтый баннер в UI, можно включать тестовый API)
// APP_ENV=production или не задана → production (баннера нет)
env: process.env.APP_ENV || 'production',
// ── IAM ──────────────────────────────────────────────────────────────────
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
+87 -27
View File
@@ -1,20 +1,22 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — инициализация схемы БД
// V2 — инициализация схемы БД + миграции
//
// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск.
// ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations().
// ЗАЧЕМ:
// 1. Приложение само создаёт таблицы при старте — не нужны миграции.
// 2. v2_ префикс изолирует тестовые таблицы от основных.
// 3. Безопасно для production: IF NOT EXISTS.
// 1. Приложение само создаёт таблицы при старте — не нужны ручные DDL.
// 2. Миграции — отдельные .sql файлы в sql/migrations/, выполняются по порядку.
// 3. Безопасно для production: IF NOT EXISTS + _migrations таблица.
//
// ТАБЛИЦЫ:
// companies — client_id (W-номер), name, custom_limit
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
// audit_log — все действия: CREATE/UPDATE/DELETE, с impersonated_by
//
// ИНДЕКСЫ: на company_id для быстрых SELECT + на unique(client_id).
// companies — client_id (W-номер), name, custom_limit
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
// audit_log — все действия: CREATE/UPDATE/DELETE/RESTORE, с impersonated_by
// _migrations — какие миграции уже выполнены
// ═══════════════════════════════════════════════════════════════════════════════
const fs = require('fs');
const path = require('path');
async function ensureSchema(pool) {
await pool.query(`
-- Компании: W-номер уникален, upsert через ON CONFLICT
@@ -51,7 +53,7 @@ async function ensureSchema(pool) {
id SERIAL PRIMARY KEY,
user_email VARCHAR(255) NOT NULL,
company_id INTEGER NOT NULL,
action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE')),
action VARCHAR(32) NOT NULL,
old_value TEXT,
new_value TEXT,
entry_id INTEGER,
@@ -62,24 +64,82 @@ async function ensureSchema(pool) {
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
ON audit_log(company_id, created_at DESC);
-- Миграция: добавить RESTORE в CHECK (если ещё нет)
DO $$
DECLARE
cn text;
BEGIN
SELECT con.conname INTO cn
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
WHERE rel.relname = 'audit_log' AND con.contype = 'c';
IF cn IS NOT NULL THEN
EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn;
END IF;
EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))';
END;
$$;
-- Таблица учёта выполненных миграций
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
console.log('[db] Schema ensured');
// Выполнить неприменённые миграции
await applyMigrations(pool);
}
module.exports = { ensureSchema };
/**
* Применить новые миграции из sql/migrations/.
* Читает все .sql файлы, сортирует по имени, выполняет те,
* которых ещё нет в таблице _migrations.
*/
async function applyMigrations(pool) {
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
if (!fs.existsSync(dir)) {
console.log('[db] No migrations dir, skipping');
return;
}
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.sql'))
.sort();
if (files.length === 0) return;
// Какие уже выполнены
const done = new Set(
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
);
for (const file of files) {
if (done.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
await pool.query(sql);
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
console.log('[db] Migration applied:', file);
}
}
async function initOldMigrations(pool) {
// Миграции, которые раньше были в ensureSchema() — теперь
// фиксируем их как уже выполненные, чтобы не потерять.
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.sql'))
.sort();
if (files.length === 0) return;
// Создать таблицу если ещё нет (на случай первого запуска с миграциями)
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
const done = new Set(
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
);
for (const file of files) {
if (done.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
await pool.query(sql);
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
console.log('[db] Migration applied:', file);
}
}
module.exports = { ensureSchema, applyMigrations, initOldMigrations };
+1 -1
View File
@@ -83,7 +83,7 @@ function createUserRouter({ generateCsrfToken } = {}) {
activeClientId: clId,
user: templateUser,
includeDeleted,
version: config.version,
version: config.version, env: config.env,
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
});
} catch (e) {