import { randomBytes } from 'crypto'; import fs from 'fs'; import fsp from 'fs/promises'; import path from 'path'; import { DataSource } from 'typeorm'; import { AuthUserEntity, ServerEntity, ServerTagEntity, ServerChannelEntity, ServerRoleEntity, ServerUserRoleEntity, ServerChannelPermissionEntity, JoinRequestEntity, ServerMembershipEntity, ServerInviteEntity, ServerBanEntity, GameMatchMissEntity, ServerPluginRequirementEntity, ServerPluginEventDefinitionEntity, PluginDataEntity, ServerPluginSettingsEntity, PluginUserMetadataEntity } from '../entities'; import { serverMigrations } from '../migrations'; import { findExistingPath, isPackagedRuntime, resolvePersistentDataPath, resolveRuntimePath } from '../runtime-paths'; const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite'); const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak'; function resolveDefaultDbFile(): string { return isPackagedRuntime() ? resolvePersistentDataPath('metoyou.sqlite') : LEGACY_PACKAGED_DB_FILE; } function resolveDbFile(): string { const envPath = process.env.DB_PATH; if (envPath) { return path.resolve(envPath); } return resolveDefaultDbFile(); } const DB_FILE = resolveDbFile(); const DB_BACKUP = DB_FILE + '.bak'; const DATA_DIR = path.dirname(DB_FILE); // SQLite files start with this 16-byte header string. const SQLITE_MAGIC = 'SQLite format 3\0'; const SAVE_RETRY_DELAYS_MS = [ 25, 75, 150, 300, 600 ]; const RETRYABLE_SAVE_ERROR_CODES = new Set([ 'EPERM', 'EACCES', 'EBUSY' ]); let applicationDataSource: DataSource | undefined; let saveQueue: Promise = Promise.resolve(); function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function isRetryableSaveError(error: unknown): boolean { if (!error || typeof error !== 'object') { return false; } const code = (error as NodeJS.ErrnoException).code; return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code); } function restoreFromBackup(reason: string): Uint8Array | undefined { if (!fs.existsSync(DB_BACKUP)) { console.error(`[DB] ${reason}. No backup available - starting with a fresh database`); return undefined; } const backup = new Uint8Array(fs.readFileSync(DB_BACKUP)); if (!isValidSqlite(backup)) { console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`); return undefined; } fs.copyFileSync(DB_BACKUP, DB_FILE); console.warn('[DB] Restored database from backup', DB_BACKUP); return backup; } async function migrateLegacyPackagedDatabase(): Promise { if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) { return; } let migrated = false; if (!fs.existsSync(DB_FILE)) { if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) { await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE); migrated = true; } else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) { await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE); migrated = true; } } if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) { await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP); migrated = true; } if (migrated) { console.log('[DB] Migrated packaged database files to:', DATA_DIR); console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE); } } /** * Returns true when `data` looks like a valid SQLite file * (correct header magic and at least one complete page). */ function isValidSqlite(data: Uint8Array): boolean { if (data.length < 100) return false; const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii'); return header === SQLITE_MAGIC; } /** * Back up the current DB file so there is always a recovery point. * If the main file is corrupted/empty but a valid backup exists, * restore the backup before the server loads the database. */ function safeguardDbFile(): Uint8Array | undefined { if (!fs.existsSync(DB_FILE)) { console.warn(`[DB] ${DB_FILE} is missing - checking backup`); return restoreFromBackup('Database file missing'); } const data = new Uint8Array(fs.readFileSync(DB_FILE)); if (isValidSqlite(data)) { // Good file - rotate it into the backup slot. fs.copyFileSync(DB_FILE, DB_BACKUP); console.log('[DB] Backed up database to', DB_BACKUP); return data; } // The main file is corrupt or empty. console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`); return restoreFromBackup(`Database file is invalid (${data.length} bytes)`); } function resolveSqlJsConfig(): { locateFile: (file: string) => string } { return { locateFile: (file) => { const bundledBinaryPath = path.join(__dirname, '..', '..', 'node_modules', 'sql.js', 'dist', file); return findExistingPath( resolveRuntimePath(file), bundledBinaryPath ) ?? bundledBinaryPath; } }; } /** * Write the database to disk atomically: write a temp file first, * then rename it over the real file. rename() is atomic on the same * filesystem, so a crash mid-write can never leave a half-written DB. */ async function replaceDatabaseFile(tmpPath: string): Promise { for (let attempt = 0; ; attempt++) { try { await fsp.rename(tmpPath, DB_FILE); return; } catch (error) { const delay = SAVE_RETRY_DELAYS_MS[attempt]; if (!isRetryableSaveError(error) || delay === undefined) { throw error; } await wait(delay); } } } async function writeDatabaseSnapshot(snapshot: Buffer): Promise { const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex'); try { await fsp.writeFile(tmpPath, snapshot); await replaceDatabaseFile(tmpPath); } catch (err) { await fsp.unlink(tmpPath).catch(() => {}); throw err; } } async function atomicSave(data: Uint8Array): Promise { const snapshot = Buffer.from(data); const saveTask = saveQueue.then( () => writeDatabaseSnapshot(snapshot), () => writeDatabaseSnapshot(snapshot) ); saveQueue = saveTask.catch(() => {}); return saveTask; } export function getDataSource(): DataSource { if (!applicationDataSource?.isInitialized) { throw new Error('DataSource not initialised'); } return applicationDataSource; } export async function initDatabase(): Promise { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); await migrateLegacyPackagedDatabase(); const database = safeguardDbFile(); try { applicationDataSource = new DataSource({ type: 'sqljs', database, entities: [ AuthUserEntity, ServerEntity, ServerTagEntity, ServerChannelEntity, ServerRoleEntity, ServerUserRoleEntity, ServerChannelPermissionEntity, JoinRequestEntity, ServerMembershipEntity, ServerInviteEntity, ServerBanEntity, GameMatchMissEntity, ServerPluginRequirementEntity, ServerPluginEventDefinitionEntity, PluginDataEntity, ServerPluginSettingsEntity, PluginUserMetadataEntity ], migrations: serverMigrations, synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: false, autoSave: true, autoSaveCallback: atomicSave, sqlJsConfig: resolveSqlJsConfig() }); } catch (error) { console.error('[DB] Failed to configure the sql.js data source', error); throw error; } try { await applicationDataSource.initialize(); } catch (error) { console.error('[DB] Failed to initialise the sql.js data source', error); throw error; } console.log('[DB] Connection initialised at:', DB_FILE); if (process.env.DB_SYNCHRONIZE !== 'true') { await applicationDataSource.runMigrations(); console.log('[DB] Migrations executed'); } else { console.log('[DB] Synchronize mode - migrations skipped'); } } export async function destroyDatabase(): Promise { if (applicationDataSource?.isInitialized) { await applicationDataSource.destroy(); applicationDataSource = undefined; console.log('[DB] Connection closed'); } }