/** * Single-process SQLite connection, migrations, PRAGMAs. * Designed so the same API can later move to a dedicated storage service process. */ const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); let _db = null; let _cachePurgeTimer = null; function defaultDbPath() { return path.join(__dirname, '..', 'data', 'router-lists.db'); } function getDbPath() { const p = process.env.SQLITE_PATH || defaultDbPath(); const dir = path.dirname(p); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // Ensure the file exists before better-sqlite3 opens it. // This makes container startup resilient when SQLITE_PATH points to // a persistent volume and database file is not created yet. if (!fs.existsSync(p)) { fs.closeSync(fs.openSync(p, 'a')); } try { fs.accessSync(p, fs.constants.R_OK | fs.constants.W_OK); } catch (err) { const details = err && err.message ? err.message : String(err); throw new Error(`SQLite file is not readable/writable at "${p}": ${details}`); } return p; } function applyMigration001(db) { const sql = fs.readFileSync(path.join(__dirname, 'migrations', '001_init.sql'), 'utf8'); db.exec(sql); } function applyMigration002(db) { const sql = fs.readFileSync(path.join(__dirname, 'migrations', '002_filter_rules.sql'), 'utf8'); db.exec(sql); const migrateBlobToRules = (objectKey, scope) => { const row = db.prepare('SELECT body FROM blobs WHERE object_key = ?').get(objectKey); if (!row) return; let bodyStr = ''; try { bodyStr = Buffer.isBuffer(row.body) ? row.body.toString('utf8') : String(row.body); } catch { return; } let arr = []; try { const parsed = JSON.parse(bodyStr); arr = Array.isArray(parsed) ? parsed : []; } catch { return; } const now = Date.now(); const delRules = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?'); const ins = db.prepare( `INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ); const run = () => { delRules.run(scope); for (let i = 0; i < arr.length; i++) { const it = arr[i] || {}; const community = String(it.community || '').trim(); const gateway = String(it.gateway || '').trim(); const description = String(it.description || '').trim(); const extra = { ...it }; delete extra.community; delete extra.gateway; delete extra.description; ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now); } db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run( `filter_rules_mtime_${scope}`, String(now), ); db.prepare('DELETE FROM blob_versions WHERE object_key = ?').run(objectKey); db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey); }; db.transaction(run)(); }; migrateBlobToRules('filters.json', 'global'); migrateBlobToRules('filter-manager/simple-filters.json', 'simple'); } /** * @returns {import('better-sqlite3').Database} */ function openDatabase() { if (_db) return _db; const filePath = getDbPath(); const db = new Database(filePath); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); db.pragma('foreign_keys = ON'); db.pragma(`busy_timeout = ${Number(process.env.SQLITE_BUSY_MS) || 8000}`); const cacheKb = Number(process.env.SQLITE_CACHE_KB); if (Number.isFinite(cacheKb) && cacheKb !== 0) { db.pragma(`cache_size = ${-Math.abs(Math.floor(cacheKb))}`); } let userVersion = db.pragma('user_version', { simple: true }); if (userVersion < 1) { applyMigration001(db); db.pragma('user_version = 1'); const now = Date.now(); db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(now); userVersion = 1; } if (userVersion < 2) { applyMigration002(db); db.pragma('user_version = 2'); const now2 = Date.now(); db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (2, ?)').run(now2); } _db = db; if (!_cachePurgeTimer) { const intervalMs = Math.max(60_000, Number(process.env.SQLITE_CACHE_PURGE_MS) || 300_000); _cachePurgeTimer = setInterval(purgeExpiredCacheEntries, intervalMs); _cachePurgeTimer.unref?.(); } return db; } function purgeExpiredCacheEntries() { const db = openDatabase(); const now = Date.now(); try { db.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now); } catch (_) {} } module.exports = { openDatabase, getDbPath, purgeExpiredCacheEntries, };