204 lines
5.5 KiB
JavaScript
204 lines
5.5 KiB
JavaScript
const Database = require('better-sqlite3');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
|
||
const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql';
|
||
|
||
function hashPassword(password) {
|
||
return crypto.createHash('sha256').update(password).digest('hex');
|
||
}
|
||
|
||
function getSqliteDbPath() {
|
||
return process.env.SQLITE_DB_PATH || path.join(process.cwd(), '.data', 'moontv.db');
|
||
}
|
||
|
||
function ensureDataDir(dbPath) {
|
||
const dataDir = path.dirname(dbPath);
|
||
if (!fs.existsSync(dataDir)) {
|
||
fs.mkdirSync(dataDir, { recursive: true });
|
||
}
|
||
}
|
||
|
||
function configureDatabase(db) {
|
||
db.pragma('journal_mode = WAL');
|
||
db.pragma('foreign_keys = ON');
|
||
db.pragma('busy_timeout = 5000');
|
||
}
|
||
|
||
function getMigrationFiles() {
|
||
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
|
||
}
|
||
|
||
return fs
|
||
.readdirSync(MIGRATIONS_DIR)
|
||
.filter((file) => file.endsWith('.sql'))
|
||
.sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
function isIgnorableMigrationError(error) {
|
||
const message = error instanceof Error ? error.message : String(error || '');
|
||
return (
|
||
message.includes('table') && message.includes('already exists') ||
|
||
message.includes('index') && message.includes('already exists') ||
|
||
message.includes('duplicate column name')
|
||
);
|
||
}
|
||
|
||
function splitSqlStatements(sql) {
|
||
const withoutLineComments = sql
|
||
.split('\n')
|
||
.filter((line) => !line.trim().startsWith('--'))
|
||
.join('\n');
|
||
|
||
return withoutLineComments
|
||
.split(';')
|
||
.map((statement) => statement.trim())
|
||
.filter((statement) => statement.length > 0);
|
||
}
|
||
|
||
function tableExists(db, tableName) {
|
||
const row = db
|
||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||
.get(tableName);
|
||
return Boolean(row);
|
||
}
|
||
|
||
function ensureMigrationTable(db) {
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
filename TEXT PRIMARY KEY,
|
||
applied_at INTEGER NOT NULL
|
||
)
|
||
`);
|
||
}
|
||
|
||
function getAppliedMigrations(db) {
|
||
return new Set(
|
||
db.prepare('SELECT filename FROM schema_migrations').all().map((row) => row.filename)
|
||
);
|
||
}
|
||
|
||
function markMigrationApplied(db, filename) {
|
||
db.prepare(
|
||
'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)'
|
||
).run(filename, Date.now());
|
||
}
|
||
|
||
function seedExistingMigrationBaseline(db, migrationFiles, hadExistingSchema) {
|
||
const applied = getAppliedMigrations(db);
|
||
if (!hadExistingSchema || applied.size > 0) return;
|
||
|
||
for (const file of migrationFiles) {
|
||
if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) {
|
||
markMigrationApplied(db, file);
|
||
}
|
||
}
|
||
}
|
||
|
||
function runMigrations(db) {
|
||
const migrationFiles = getMigrationFiles();
|
||
const hadExistingSchema = tableExists(db, 'users');
|
||
ensureMigrationTable(db);
|
||
seedExistingMigrationBaseline(db, migrationFiles, hadExistingSchema);
|
||
|
||
for (const file of migrationFiles) {
|
||
const applied = getAppliedMigrations(db);
|
||
if (applied.has(file)) {
|
||
console.log(`⏭️ Migration already applied: ${file}`);
|
||
continue;
|
||
}
|
||
|
||
const migrationPath = path.join(MIGRATIONS_DIR, file);
|
||
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||
const statements = splitSqlStatements(sql);
|
||
|
||
console.log(`▶️ Applying migration: ${file}`);
|
||
for (const statement of statements) {
|
||
try {
|
||
db.exec(statement);
|
||
} catch (error) {
|
||
if (isIgnorableMigrationError(error)) {
|
||
console.log(`⏭️ Statement skipped in ${file}: ${error.message}`);
|
||
continue;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
markMigrationApplied(db, file);
|
||
console.log(`✅ Migration applied: ${file}`);
|
||
}
|
||
}
|
||
|
||
function ensureDefaultAdmin(db) {
|
||
const username = process.env.USERNAME || 'admin';
|
||
const password = process.env.PASSWORD || '123456789';
|
||
const passwordHash = hashPassword(password);
|
||
|
||
const existingUser = db
|
||
.prepare('SELECT username FROM users WHERE username = ? LIMIT 1')
|
||
.get(username);
|
||
|
||
if (existingUser) {
|
||
console.log(`ℹ️ Admin user already exists: ${username}`);
|
||
return;
|
||
}
|
||
|
||
db.prepare(`
|
||
INSERT INTO users (
|
||
username, password_hash, role, created_at,
|
||
playrecord_migrated, favorite_migrated, skip_migrated
|
||
)
|
||
VALUES (?, ?, 'owner', ?, 1, 1, 1)
|
||
`).run(username, passwordHash, Date.now());
|
||
|
||
console.log(`✅ Default admin user created: ${username}`);
|
||
}
|
||
|
||
function initSQLiteDatabase() {
|
||
const dbPath = getSqliteDbPath();
|
||
ensureDataDir(dbPath);
|
||
|
||
let db;
|
||
try {
|
||
db = new Database(dbPath);
|
||
} catch (error) {
|
||
if (error && typeof error.message === 'string' && error.message.includes('Could not locate the bindings file')) {
|
||
console.error('❌ better-sqlite3 native binding is missing or incompatible with current Node.js runtime.');
|
||
console.error('💡 Please run: pnpm rebuild better-sqlite3');
|
||
console.error('💡 If you recently changed Node.js version, reinstall dependencies or rebuild native modules.');
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
configureDatabase(db);
|
||
|
||
console.log('📦 Initializing SQLite database...');
|
||
console.log('📍 Database location:', dbPath);
|
||
|
||
try {
|
||
runMigrations(db);
|
||
ensureDefaultAdmin(db);
|
||
} finally {
|
||
db.close();
|
||
}
|
||
|
||
console.log('🎉 SQLite database is ready!');
|
||
}
|
||
|
||
module.exports = {
|
||
initSQLiteDatabase,
|
||
getSqliteDbPath,
|
||
};
|
||
|
||
if (require.main === module) {
|
||
try {
|
||
initSQLiteDatabase();
|
||
} catch (err) {
|
||
console.error('❌ SQLite initialization failed:', err);
|
||
process.exit(1);
|
||
}
|
||
}
|