Publish Docker image / build-and-push (push) Successful in 1m27s
- Added checks to ensure the SQLite database file exists before opening, improving resilience during container startup. - Implemented error handling for file accessibility, providing clearer error messages if the database file is not readable or writable. Made-with: Cursor
156 lines
4.7 KiB
JavaScript
156 lines
4.7 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|