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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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. |
|
||||
Reference in New Issue
Block a user