feat(db): add versioned SQLite migrations

Track applied schema changes in schema_migrations and run pending migrations on startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 10:20:27 +02:00
co-authored by Cursor
parent 1261a9b482
commit 2c5aa01a9a
5 changed files with 144 additions and 50 deletions
+4 -1
View File
@@ -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}`)
}
+66
View File
@@ -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,
)
}
+13 -49
View File
@@ -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')
}
@@ -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
);
`
+11
View File
@@ -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 },
]