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