diff --git a/src/database/database.service.ts b/src/database/database.service.ts index e4be839..5306be8 100644 --- a/src/database/database.service.ts +++ b/src/database/database.service.ts @@ -23,7 +23,10 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy { mkdirSync(dirname(this.dbPath), { recursive: true }) this.db = new Database(this.dbPath) this.db.pragma('journal_mode = WAL') - runSchemaMigrations(this.db) + const applied = runSchemaMigrations(this.db) + if (applied.length > 0) { + this.logger.log(`Applied migrations: ${applied.join(', ')}`) + } this.logger.log(`SQLite ready at ${this.dbPath}`) } diff --git a/src/database/migration-runner.ts b/src/database/migration-runner.ts new file mode 100644 index 0000000..475d3b1 --- /dev/null +++ b/src/database/migration-runner.ts @@ -0,0 +1,66 @@ +import type Database from 'better-sqlite3' +import { SQL_MIGRATIONS } from './migrations' + +const MIGRATIONS_TABLE = 'schema_migrations' + +function ensureMigrationsTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ); + `) +} + +function isMigrationApplied(db: Database.Database, id: string): boolean { + const row = db + .prepare(`SELECT id FROM ${MIGRATIONS_TABLE} WHERE id = ?`) + .get(id) as { id: string } | undefined + return row !== undefined +} + +function recordMigrationApplied(db: Database.Database, id: string): void { + db.prepare(`INSERT INTO ${MIGRATIONS_TABLE} (id, applied_at) VALUES (?, ?)`).run( + id, + new Date().toISOString(), + ) +} + +/** Run pending versioned migrations inside a transaction per migration. */ +export function runPendingMigrations(db: Database.Database): string[] { + ensureMigrationsTable(db) + + const applied: string[] = [] + + for (const migration of SQL_MIGRATIONS) { + if (isMigrationApplied(db, migration.id)) { + continue + } + + const apply = db.transaction(() => { + db.exec(migration.up) + recordMigrationApplied(db, migration.id) + }) + + apply() + applied.push(migration.id) + } + + return applied +} + +export function listAppliedMigrationIds(db: Database.Database): string[] { + ensureMigrationsTable(db) + const rows = db + .prepare(`SELECT id FROM ${MIGRATIONS_TABLE} ORDER BY id`) + .all() as Array<{ id: string }> + return rows.map((row) => row.id) +} + +export function listPendingMigrationIds(db: Database.Database): string[] { + ensureMigrationsTable(db) + const applied = new Set(listAppliedMigrationIds(db)) + return SQL_MIGRATIONS.filter((migration) => !applied.has(migration.id)).map( + (migration) => migration.id, + ) +} diff --git a/src/database/migration.service.ts b/src/database/migration.service.ts index 2b1b23d..a8ff1e6 100644 --- a/src/database/migration.service.ts +++ b/src/database/migration.service.ts @@ -1,55 +1,17 @@ import type Database from 'better-sqlite3' +import { + listAppliedMigrationIds, + listPendingMigrationIds, + runPendingMigrations, +} from './migration-runner' -/** Idempotent schema setup — mirrors backend/db.ts + backend/migrations.ts (no destructive ops). */ -export function runSchemaMigrations(db: Database.Database): void { - db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - state_json TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS user_level_progress ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - level_id INTEGER NOT NULL, - best_score INTEGER NOT NULL DEFAULT 0, - stars INTEGER NOT NULL DEFAULT 0, - attempts_count INTEGER NOT NULL DEFAULT 0, - started_at TEXT, - completed_at TEXT, - updated_at TEXT NOT NULL, - UNIQUE(user_id, level_id) - ); - - CREATE TABLE IF NOT EXISTS reward_ledger ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - source_type TEXT NOT NULL, - source_id TEXT NOT NULL, - reward_key TEXT NOT NULL, - xp_delta INTEGER NOT NULL DEFAULT 0, - coins_delta INTEGER NOT NULL DEFAULT 0, - metadata_json TEXT, - created_at TEXT NOT NULL, - UNIQUE(user_id, reward_key) - ); - - CREATE INDEX IF NOT EXISTS idx_user_level_progress_user - ON user_level_progress(user_id); - CREATE INDEX IF NOT EXISTS idx_reward_ledger_user - ON reward_ledger(user_id); - - CREATE TABLE IF NOT EXISTS auth_accounts ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - user_id TEXT NOT NULL, - created_at TEXT NOT NULL - ); - `) +/** Apply all pending versioned SQL migrations (safe to call on every startup). */ +export function runSchemaMigrations(db: Database.Database): string[] { + return runPendingMigrations(db) } +export { listAppliedMigrationIds, listPendingMigrationIds } + export const EXPECTED_TABLES = [ 'users', 'user_level_progress', @@ -61,5 +23,7 @@ export function listUserTables(db: Database.Database): string[] { const rows = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") .all() as Array<{ name: string }> - return rows.map((row) => row.name) + return rows + .map((row) => row.name) + .filter((name) => name !== 'schema_migrations') } diff --git a/src/database/migrations/001-initial-schema.ts b/src/database/migrations/001-initial-schema.ts new file mode 100644 index 0000000..03a7a8d --- /dev/null +++ b/src/database/migrations/001-initial-schema.ts @@ -0,0 +1,50 @@ +/** Initial application schema — idempotent (CREATE IF NOT EXISTS). */ +export const migrationId = '001_initial_schema' + +export const up = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_level_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + level_id INTEGER NOT NULL, + best_score INTEGER NOT NULL DEFAULT 0, + stars INTEGER NOT NULL DEFAULT 0, + attempts_count INTEGER NOT NULL DEFAULT 0, + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL, + UNIQUE(user_id, level_id) +); + +CREATE TABLE IF NOT EXISTS reward_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + reward_key TEXT NOT NULL, + xp_delta INTEGER NOT NULL DEFAULT 0, + coins_delta INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT, + created_at TEXT NOT NULL, + UNIQUE(user_id, reward_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_level_progress_user + ON user_level_progress(user_id); + +CREATE INDEX IF NOT EXISTS idx_reward_ledger_user + ON reward_ledger(user_id); + +CREATE TABLE IF NOT EXISTS auth_accounts ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL +); +` diff --git a/src/database/migrations/index.ts b/src/database/migrations/index.ts new file mode 100644 index 0000000..f6ce06c --- /dev/null +++ b/src/database/migrations/index.ts @@ -0,0 +1,11 @@ +import { migrationId as id001, up as up001 } from './001-initial-schema' + +export interface SqlMigration { + id: string + up: string +} + +/** Ordered list — append new files and import them here. */ +export const SQL_MIGRATIONS: SqlMigration[] = [ + { id: id001, up: up001 }, +]