feat: versioned migrations runner (phase 1 — no behaviour change)

Adds backend/src/db/migrations-runner.js:
- Tracks applied migrations in _migrations table
- Applies .sql files from src/db/migrations/ in alphabetical order
- Each file runs in a transaction — fail-fast, no partial state
- `migrate:bootstrap` marks 000_baseline.sql as applied on existing DBs

000_baseline.sql — full schema snapshot from prod DB (168 objects, 2026-05-06).
Removed stale PostgreSQL migration files (001_init.sql, 002_constraints.sql)
that used SERIAL/EXTENSION syntax incompatible with SQLite.

npm scripts:
  migrate           → migrations-runner.js (versioned)
  migrate:bootstrap → mark baseline applied (run once per env)
  migrate:legacy    → legacy migrate.js (kept for reference)

On prod DB after `migrate:bootstrap`: "Nothing to apply — schema is up to date".
Legacy migrate.js still in place; tests still use it via setup.js (unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-06 17:47:59 +03:00
parent 25489a733a
commit 41d4465905
6 changed files with 1166 additions and 106 deletions
+3 -1
View File
@@ -6,7 +6,9 @@
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"migrate": "node src/db/migrate.js",
"migrate": "node src/db/migrations-runner.js",
"migrate:bootstrap": "node src/db/migrations-runner.js bootstrap",
"migrate:legacy": "node src/db/migrate.js",
"seed": "node src/db/seed.js",
"seed:permissions": "node src/db/seed-permissions.js",
"lint:routes": "node scripts/check-route-auth.js",
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const fs = require('fs');
const path = require('path');
const db = require('./db');
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
function init() {
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
filename TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
function listFiles() {
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
return fs.readdirSync(MIGRATIONS_DIR)
.filter(f => f.endsWith('.sql'))
.sort();
}
function applied() {
return new Set(db.prepare('SELECT filename FROM _migrations').all().map(r => r.filename));
}
function run() {
init();
const done = applied();
const files = listFiles();
const pending = files.filter(f => !done.has(f));
if (pending.length === 0) {
console.log('[migrate] Nothing to apply — schema is up to date');
return;
}
for (const file of pending) {
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
console.log(`[migrate] Applying ${file}...`);
try {
db.exec('BEGIN');
db.exec(sql);
db.prepare('INSERT INTO _migrations (filename) VALUES (?)').run(file);
db.exec('COMMIT');
console.log(`[migrate] OK: ${file}`);
} catch (e) {
db.exec('ROLLBACK');
console.error(`[migrate] FAILED at ${file}: ${e.message}`);
process.exit(1);
}
}
console.log(`[migrate] Applied ${pending.length} migration(s)`);
}
/* Mark 000_baseline.sql as applied on existing DBs without running it.
Run once per environment after first deploy of this system. */
function markBaseline() {
init();
const hasUsers = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get();
if (hasUsers) {
const done = applied();
if (!done.has('000_baseline.sql')) {
db.prepare('INSERT INTO _migrations (filename) VALUES (?)').run('000_baseline.sql');
console.log('[migrate] Marked 000_baseline.sql as applied (existing DB — not re-run)');
} else {
console.log('[migrate] 000_baseline.sql already marked as applied');
}
} else {
console.log('[migrate] No users table found — run `npm run migrate` first to initialize schema');
process.exit(1);
}
}
if (require.main === module) {
const cmd = process.argv[2];
if (cmd === 'bootstrap') markBaseline();
else run();
}
module.exports = { run, markBaseline };
File diff suppressed because it is too large Load Diff
-102
View File
@@ -1,102 +0,0 @@
-- =============================================
-- LearnSpace — Initial schema
-- =============================================
-- Расширения
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Пользователи ──────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'student'
CHECK (role IN ('student', 'teacher', 'admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login TIMESTAMPTZ
);
-- ── Предметы ──────────────────────────────────
CREATE TABLE IF NOT EXISTS subjects (
id SERIAL PRIMARY KEY,
slug VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
icon VARCHAR(10)
);
INSERT INTO subjects (slug, name, icon) VALUES
('bio', 'Биология', 'dna'),
('chem', 'Химия', 'atom'),
('math', 'Математика', 'compass'),
('phys', 'Физика', 'zap')
ON CONFLICT DO NOTHING;
-- ── Темы ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS topics (
id SERIAL PRIMARY KEY,
subject_id INT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
order_index INT NOT NULL DEFAULT 0
);
-- ── Банк вопросов ─────────────────────────────
CREATE TABLE IF NOT EXISTS questions (
id SERIAL PRIMARY KEY,
subject_id INT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
topic_id INT REFERENCES topics(id) ON DELETE SET NULL,
text TEXT NOT NULL,
difficulty SMALLINT NOT NULL DEFAULT 1 CHECK (difficulty BETWEEN 1 AND 3),
year SMALLINT,
explanation TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Варианты ответов ──────────────────────────
CREATE TABLE IF NOT EXISTS options (
id SERIAL PRIMARY KEY,
question_id INT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
order_index SMALLINT NOT NULL DEFAULT 0
);
-- ── Сессии тестирования ───────────────────────
CREATE TABLE IF NOT EXISTS test_sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id INT REFERENCES subjects(id) ON DELETE SET NULL,
mode VARCHAR(20) NOT NULL DEFAULT 'exam'
CHECK (mode IN ('exam', 'practice', 'topic', 'random')),
total INT NOT NULL,
score INT,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed', 'abandoned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
-- ── Вопросы сессии ────────────────────────────
CREATE TABLE IF NOT EXISTS session_questions (
id SERIAL PRIMARY KEY,
session_id INT NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE,
question_id INT NOT NULL REFERENCES questions(id),
order_index INT NOT NULL
);
-- ── Ответы пользователя ───────────────────────
CREATE TABLE IF NOT EXISTS user_answers (
id SERIAL PRIMARY KEY,
session_id INT NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE,
question_id INT NOT NULL REFERENCES questions(id),
chosen_option_id INT REFERENCES options(id),
is_correct BOOLEAN,
time_spent_sec SMALLINT,
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Индексы ───────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_questions_subject ON questions(subject_id);
CREATE INDEX IF NOT EXISTS idx_questions_topic ON questions(topic_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON test_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_answers_session ON user_answers(session_id);
@@ -1,3 +0,0 @@
-- Уникальный индекс для upsert ответов
CREATE UNIQUE INDEX IF NOT EXISTS idx_answers_session_question
ON user_answers (session_id, question_id);
+63
View File
@@ -0,0 +1,63 @@
# Versioned migrations
Each schema change is a separate `.sql` file, applied in alphabetical order.
Applied files are tracked in the `_migrations` table.
## Applying migrations
```sh
npm run migrate # apply pending migrations (safe to re-run)
npm run migrate:bootstrap # mark 000_baseline.sql as applied on existing DB (run ONCE per env)
npm run migrate:legacy # run legacy migrate.js (kept for reference, do not use)
```
## Naming convention
```
NNN_short_description.sql
```
Examples: `001_add_user_avatar.sql`, `002_drop_unused_columns.sql`
To find the next number:
```sh
ls backend/src/db/migrations/*.sql | sort -r | head -1
```
## Rules
1. **Never edit** a migration file after it has been committed and deployed.
2. To revert: write a new migration that undoes the change.
3. Each file must be valid SQLite SQL (not PostgreSQL — no SERIAL, no EXTENSION).
4. Use `IF NOT EXISTS` / `IF EXISTS` where possible for safety.
5. Test on a copy of the prod DB before deploying.
## Deploy order (first time on a new environment)
```sh
npm run migrate:legacy # initialize full schema (existing init script)
npm run seed:permissions # seed default permissions and achievements
npm run migrate:bootstrap # mark 000_baseline.sql as applied
npm run migrate # apply any newer migrations (should say "nothing to apply")
npm start
```
## Adding a new migration
```sh
# 1. Create the file
echo "ALTER TABLE users ADD COLUMN avatar_url TEXT;" > backend/src/db/migrations/001_add_avatar_url.sql
# 2. Apply and verify
npm run migrate
# 3. Commit
git add backend/src/db/migrations/001_add_avatar_url.sql
git commit -m "db: add avatar_url column to users"
```
## Files
| File | Description |
|------|-------------|
| `000_baseline.sql` | Snapshot of full schema as of 2026-05-06. Never runs on existing DBs. |