2026-05-03 19:51:57 -05:00
const Database = require ( 'better-sqlite3' ) ;
const path = require ( 'path' ) ;
const fs = require ( 'fs' ) ;
2026-05-10 09:45:39 -05:00
// Lazy import for auditService — cannot require at top level due to circular dependency
// (auditService -> database.js -> auditService). Use getLogAudit() instead of logAudit directly.
let _logAudit = null ;
function getLogAudit ( ) {
if ( ! _logAudit ) {
try { _logAudit = require ( '../services/auditService' ) . logAudit ; } catch { _logAudit = ( ) => { } ; }
}
return _logAudit ;
}
2026-05-03 19:51:57 -05:00
const DB _PATH = process . env . DB _PATH || path . join ( _ _dirname , 'bills.db' ) ;
const SCHEMA _PATH = path . join ( _ _dirname , 'schema.sql' ) ;
2026-05-04 16:38:03 -05:00
const DEFAULT _CATEGORIES = [
'Housing' ,
'Utilities' ,
'Credit Cards' ,
'Loans' ,
'Insurance' ,
'Subscriptions' ,
'Phone & Internet' ,
'Transportation' ,
'Medical' ,
'Other' ,
] ;
2026-05-03 19:51:57 -05:00
2026-05-09 13:03:36 -05:00
// ── SQL Whitelist Mappings ────────────────────────────────────────────────────
// Security FIX (2026-05-08): Whitelist all allowed column names to prevent SQL injection
// in migrations that use dynamic ALTER TABLE statements.
const COLUMN _WHITELIST = new Set ( [
// users table columns
'active' , 'is_default_admin' , 'notification_email' , 'notifications_enabled' ,
'notify_3d' , 'notify_1d' , 'notify_due' , 'notify_overdue' ,
'display_name' , 'last_password_change_at' , 'auth_provider' , 'external_subject' ,
'email' , 'last_login_at' ,
// payments table columns
'deleted_at' ,
// monthly_starting_amounts table columns
'other_amount' ,
// bills table columns
'history_visibility' , 'interest_rate' , 'user_id' ,
2026-05-09 20:19:46 -05:00
// sessions table columns
'created_at' ,
2026-05-09 13:03:36 -05:00
] ) ;
// Security validation function for column names
function isValidColumnName ( col ) {
if ( ! col || typeof col !== 'string' ) return false ;
// Must be in whitelist AND match valid SQL identifier pattern
return COLUMN _WHITELIST . has ( col ) && /^[a-z0-9_]+$/i . test ( col ) ;
}
// Security validation function for SQL definition fragments
function isValidSqlDefinition ( def ) {
if ( ! def || typeof def !== 'string' ) return false ;
// Allow standard column definitions but reject any user input
// This is safe because all definitions are hardcoded here
return /^[\w\s\(\)\',!@#$%^&*+=\[\]<>\-.]+$/i . test ( def ) ;
}
2026-05-03 19:51:57 -05:00
fs . mkdirSync ( path . dirname ( DB _PATH ) , { recursive : true } ) ;
let db = null ;
let initializing = false ;
function assertWritableDbPath ( ) {
const dir = path . dirname ( DB _PATH ) ;
const probe = path . join ( dir , ` .write-test- ${ process . pid } - ${ Date . now ( ) } ` ) ;
try {
fs . mkdirSync ( dir , { recursive : true } ) ;
fs . writeFileSync ( probe , 'ok' ) ;
fs . unlinkSync ( probe ) ;
if ( fs . existsSync ( DB _PATH ) ) {
fs . accessSync ( DB _PATH , fs . constants . R _OK | fs . constants . W _OK ) ;
}
} catch ( err ) {
const message = [
` Database path is not writable: ${ DB _PATH } ` ,
` Ensure the DB directory is writable by the app user. In Docker, rebuild the image and recreate the container so the entrypoint can chown /data. ` ,
` Original error: ${ err . message } ` ,
] . join ( '\n' ) ;
const wrapped = new Error ( message ) ;
wrapped . code = err . code ;
throw wrapped ;
} finally {
try {
if ( fs . existsSync ( probe ) ) fs . unlinkSync ( probe ) ;
} catch { }
}
}
function sleep ( ms ) {
Atomics . wait ( new Int32Array ( new SharedArrayBuffer ( 4 ) ) , 0 , 0 , ms ) ;
}
function getDb ( ) {
// already ready
if ( db ) return db ;
// wait if another init is happening
while ( initializing ) {
sleep ( 50 ) ;
}
// check again after wait
if ( db ) return db ;
initializing = true ;
try {
2026-05-10 09:45:39 -05:00
console . log ( 'Opening DB at:' , path . basename ( DB _PATH ) ) ;
2026-05-03 19:51:57 -05:00
assertWritableDbPath ( ) ;
db = new Database ( DB _PATH , {
timeout : 5000
} ) ;
db . pragma ( 'busy_timeout = 5000' ) ;
try {
db . pragma ( 'journal_mode = WAL' ) ;
} catch ( e ) {
console . warn ( 'WAL failed:' , e . message ) ;
}
db . pragma ( 'foreign_keys = ON' ) ;
initSchema ( ) ;
seedDefaults ( ) ;
console . log ( 'DB initialized successfully' ) ;
return db ;
} catch ( err ) {
console . error ( 'DB init failed:' , err ) ;
throw err ;
} finally {
initializing = false ;
}
}
function initSchema ( ) {
const schema = fs . readFileSync ( SCHEMA _PATH , 'utf8' ) ;
db . exec ( schema ) ;
2026-05-09 15:17:40 -05:00
// Create schema_migrations table for tracking applied migrations
db . exec ( `
CREATE TABLE IF NOT EXISTS schema _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
version TEXT NOT NULL UNIQUE ,
description TEXT NOT NULL ,
applied _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
)
` );
2026-05-09 18:25:25 -05:00
// Check if this is a legacy database (tables exist but no migration tracking)
handleLegacyDatabase ( ) ;
2026-05-09 19:47:00 -05:00
// After legacy reconciliation and user seeding, reset the default admin password
// when INIT_ADMIN_PASS is set. This ensures legacy DBs can be accessed after migration.
// The must_change_password flag forces the admin to pick a new password on first login.
if ( process . env . INIT _ADMIN _PASS ) {
const initUser = process . env . INIT _ADMIN _USER || 'admin' ;
const initPass = process . env . INIT _ADMIN _PASS ;
const bcrypt = require ( 'bcryptjs' ) ;
const newPasswordHash = bcrypt . hashSync ( initPass , 12 ) ;
// Reset password for the default admin user if INIT_ADMIN_PASS is set
const result = db . prepare ( `
2026-05-10 04:24:51 -05:00
UPDATE users SET password _hash = ? , first _login = 0 , must _change _password = 0
2026-05-09 19:47:00 -05:00
WHERE username = ? AND is _default _admin = 1
` ).run(newPasswordHash, initUser);
if ( result . changes > 0 ) {
2026-05-10 04:24:51 -05:00
console . log ( '[init] Reset password and flags for default admin user' ) ;
2026-05-09 19:47:00 -05:00
}
}
2026-05-03 19:51:57 -05:00
runMigrations ( ) ;
}
2026-05-09 15:17:40 -05:00
function hasMigrationBeenApplied ( version ) {
const stmt = db . prepare ( 'SELECT 1 FROM schema_migrations WHERE version = ?' ) ;
return ! ! stmt . get ( version ) ;
}
2026-05-09 18:25:25 -05:00
function handleLegacyDatabase ( ) {
// Check if schema_migrations table exists but is empty
// This indicates a legacy database that predates migration tracking
const migrationCount = db . prepare ( 'SELECT COUNT(*) as count FROM schema_migrations' ) . get ( ) . count ;
if ( migrationCount === 0 ) {
// This might be a legacy database. Check if core tables exist.
const tableCheck = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users', 'bills', 'payments', 'categories', 'settings')" ) . all ( ) ;
// If we have core tables but no migrations tracked, this is likely a legacy DB
if ( tableCheck . length >= 3 ) { // At least some core tables exist
console . log ( '[migration] Detected legacy database, reconciling schema migrations...' ) ;
// For each migration, check if its changes are already present and mark as applied if so
reconcileLegacyMigrations ( ) ;
}
}
}
function reconcileLegacyMigrations ( ) {
// Define all migrations with explicit version tracking
const migrations = [
{
version : 'v0.2' ,
description : 'payments: soft-delete column' ,
check : function ( ) {
const paymentCols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
return paymentCols . includes ( 'deleted_at' ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const paymentCols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! paymentCols . includes ( 'deleted_at' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN deleted_at TEXT' ) ;
// Index for fast filtering of live payments
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)' ) ;
console . log ( '[migration] payments.deleted_at column added' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.3' ,
description : 'payments: compound index for tracker query' ,
check : function ( ) {
// Check if the index exists
const indexes = db . prepare ( "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'" ) . all ( ) ;
return indexes . length > 0 ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.4' ,
description : 'monthly_bill_state: per-bill per-month overrides' ,
check : function ( ) {
const tables = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'" ) . all ( ) ;
return tables . length > 0 ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS monthly _bill _state (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL CHECK ( year BETWEEN 2000 AND 2100 ) ,
month INTEGER NOT NULL CHECK ( month BETWEEN 1 AND 12 ) ,
actual _amount REAL ,
notes TEXT ,
is _skipped INTEGER NOT NULL DEFAULT 0 ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( bill _id , year , month )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)' ) ;
console . log ( '[migration] monthly_bill_state table ensured' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.13' ,
description : 'users: profile columns' ,
check : function ( ) {
const userColsNow = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const profileCols = [ 'display_name' , 'last_password_change_at' ] ;
return profileCols . every ( col => userColsNow . includes ( col ) ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const userColsNow = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const profileCols = [
[ 'display_name' , 'TEXT' ] ,
[ 'last_password_change_at' , 'TEXT' ] ,
] ;
for ( const [ col , def ] of profileCols ) {
if ( ! userColsNow . includes ( col ) ) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if ( ! isValidColumnName ( col ) || ! isValidSqlDefinition ( def ) ) {
throw new Error ( ` Invalid migration: column ' ${ col } ' not in whitelist ` ) ;
}
db . exec ( ` ALTER TABLE users ADD COLUMN ${ col } ${ def } ` ) ;
}
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.14' ,
description : 'bills: history visibility mode' ,
check : function ( ) {
const billColsHist = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return billColsHist . includes ( 'history_visibility' ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const billColsHist = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billColsHist . includes ( 'history_visibility' ) ) {
db . exec ( "ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'" ) ;
console . log ( '[migration] bills.history_visibility column added' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.14.4' ,
description : 'bills: optional credit-card APR / interest rate' ,
check : function ( ) {
const billColsInterest = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return billColsInterest . includes ( 'interest_rate' ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const billColsInterest = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billColsInterest . includes ( 'interest_rate' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN interest_rate REAL' ) ;
console . log ( '[migration] bills.interest_rate column added' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.15' ,
description : 'import_sessions and import_history tables' ,
check : function ( ) {
const tables = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')" ) . all ( ) ;
return tables . length >= 2 ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS import _sessions (
id TEXT PRIMARY KEY ,
user _id INTEGER NOT NULL ,
created _at TEXT NOT NULL ,
expires _at TEXT NOT NULL ,
preview _json TEXT NOT NULL
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)' ) ;
// ── import_history: per-user audit log (v0.38) ────────────────────────────
db . exec ( `
CREATE TABLE IF NOT EXISTS import _history (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL ,
imported _at TEXT NOT NULL ,
source _filename TEXT ,
file _type TEXT DEFAULT 'xlsx' ,
sheet _name TEXT ,
rows _parsed INTEGER DEFAULT 0 ,
rows _created INTEGER DEFAULT 0 ,
rows _updated INTEGER DEFAULT 0 ,
rows _skipped INTEGER DEFAULT 0 ,
rows _ambiguous INTEGER DEFAULT 0 ,
rows _errored INTEGER DEFAULT 0 ,
options _json TEXT ,
summary _json TEXT
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.17' ,
description : 'users: external identity / OIDC columns' ,
check : function ( ) {
const userColsOidc = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const oidcUserCols = [ 'auth_provider' , 'external_subject' , 'email' , 'last_login_at' ] ;
return oidcUserCols . every ( col => userColsOidc . includes ( col ) ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const userColsOidc = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const oidcUserCols = [
[ 'auth_provider' , "TEXT NOT NULL DEFAULT 'local'" ] ,
[ 'external_subject' , 'TEXT' ] ,
[ 'email' , 'TEXT' ] ,
[ 'last_login_at' , 'TEXT' ] ,
] ;
for ( const [ col , def ] of oidcUserCols ) {
if ( ! userColsOidc . includes ( col ) ) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if ( ! isValidColumnName ( col ) || ! isValidSqlDefinition ( def ) ) {
throw new Error ( ` Invalid migration: column ' ${ col } ' not in whitelist ` ) ;
}
db . exec ( ` ALTER TABLE users ADD COLUMN ${ col } ${ def } ` ) ;
}
}
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
db . exec ( `
CREATE TABLE IF NOT EXISTS oidc _states (
id TEXT PRIMARY KEY ,
nonce TEXT NOT NULL ,
code _verifier TEXT NOT NULL ,
redirect _to TEXT ,
created _at TEXT NOT NULL ,
expires _at TEXT NOT NULL
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.18.1' ,
description : 'monthly_income: per-user monthly income for Summary planning' ,
check : function ( ) {
const tables = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'" ) . all ( ) ;
return tables . length > 0 ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS monthly _income (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL CHECK ( year BETWEEN 2000 AND 2100 ) ,
month INTEGER NOT NULL CHECK ( month BETWEEN 1 AND 12 ) ,
label TEXT NOT NULL DEFAULT 'Salary' ,
amount REAL NOT NULL DEFAULT 0 ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , year , month )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.18.2' ,
description : 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th' ,
check : function ( ) {
const tables = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'" ) . all ( ) ;
return tables . length > 0 ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS monthly _starting _amounts (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL CHECK ( year BETWEEN 2000 AND 2100 ) ,
month INTEGER NOT NULL CHECK ( month BETWEEN 1 AND 12 ) ,
first _amount REAL NOT NULL DEFAULT 0 CHECK ( first _amount >= 0 ) ,
fifteenth _amount REAL NOT NULL DEFAULT 0 CHECK ( fifteenth _amount >= 0 ) ,
other _amount REAL NOT NULL DEFAULT 0 CHECK ( other _amount >= 0 ) ,
notes TEXT ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , year , month )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.18.3' ,
description : 'monthly_starting_amounts: add other_amount column' ,
check : function ( ) {
const startingCols = db . prepare ( 'PRAGMA table_info(monthly_starting_amounts)' ) . all ( ) . map ( c => c . name ) ;
return startingCols . includes ( 'other_amount' ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const startingCols = db . prepare ( 'PRAGMA table_info(monthly_starting_amounts)' ) . all ( ) . map ( c => c . name ) ;
if ( ! startingCols . includes ( 'other_amount' ) ) {
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
if ( ! isValidColumnName ( 'other_amount' ) ) {
throw new Error ( 'Invalid migration: column other_amount not in whitelist' ) ;
}
db . exec ( 'ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)' ) ;
console . log ( '[migration] monthly_starting_amounts.other_amount column added' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.38' ,
description : 'import_history: per-user audit log' ,
check : function ( ) {
// Already handled in v0.15
return true ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
// This was already handled in v0.15, but keeping for completeness
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.40' ,
description : 'ownership: user-scoped bills/categories' ,
check : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const categoryCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
return billCols . includes ( 'user_id' ) && categoryCols . includes ( 'user_id' ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'user_id' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE' ) ;
}
const categoryCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! categoryCols . includes ( 'user_id' ) ) {
db . exec ( 'ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE' ) ;
}
const categorySql = db . prepare ( "SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'" ) . get ( ) ? . sql || '' ;
if ( /name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i . test ( categorySql ) ) {
db . exec ( 'PRAGMA foreign_keys = OFF' ) ;
db . exec ( `
CREATE TABLE IF NOT EXISTS categories _v040 (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER REFERENCES users ( id ) ON DELETE CASCADE ,
name TEXT NOT NULL ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) )
)
` );
db . exec ( 'INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories' ) ;
db . exec ( 'DROP TABLE categories' ) ;
db . exec ( 'ALTER TABLE categories_v040 RENAME TO categories' ) ;
db . exec ( 'PRAGMA foreign_keys = ON' ) ;
}
const firstAdmin = db . prepare ( "SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" ) . get ( ) ;
if ( firstAdmin ) {
db . prepare ( 'UPDATE bills SET user_id = ? WHERE user_id IS NULL' ) . run ( firstAdmin . id ) ;
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
db . prepare ( `
DELETE FROM categories
WHERE user _id IS NULL
AND LOWER ( name ) IN (
SELECT LOWER ( name ) FROM categories WHERE user _id = ?
)
` ).run(firstAdmin.id);
db . prepare ( 'UPDATE categories SET user_id = ? WHERE user_id IS NULL' ) . run ( firstAdmin . id ) ;
}
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)' ) ;
db . exec ( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.41' ,
description : 'bills and categories: is_seeded flag for demo data cleanup' ,
check : function ( ) {
const billColsSeeded = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const categoryColsSeeded = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
return billColsSeeded . includes ( 'is_seeded' ) && categoryColsSeeded . includes ( 'is_seeded' ) ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
const billColsSeeded = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billColsSeeded . includes ( 'is_seeded' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[migration] bills.is_seeded column added' ) ;
}
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
const categoryColsSeeded = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! categoryColsSeeded . includes ( 'is_seeded' ) ) {
db . exec ( 'ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[migration] categories.is_seeded column added' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.42' ,
description : 'bill_history_ranges: per-bill date ranges for history visibility' ,
check : function ( ) {
const tables = db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'" ) . all ( ) ;
return tables . length > 0 ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS bill _history _ranges (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
start _year INTEGER NOT NULL ,
start _month INTEGER NOT NULL ,
end _year INTEGER ,
end _month INTEGER ,
label TEXT ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)' ) ;
2026-05-09 18:25:25 -05:00
}
2026-05-09 20:19:46 -05:00
} ,
{
version : 'v0.43' ,
description : 'sessions: add created_at column' ,
check : function ( ) {
const sessionCols = db . prepare ( 'PRAGMA table_info(sessions)' ) . all ( ) . map ( c => c . name ) ;
return sessionCols . includes ( 'created_at' ) ;
} ,
run : function ( ) {
const sessionCols = db . prepare ( 'PRAGMA table_info(sessions)' ) . all ( ) . map ( c => c . name ) ;
if ( ! sessionCols . includes ( 'created_at' ) ) {
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
if ( ! isValidColumnName ( 'created_at' ) ) {
throw new Error ( 'Invalid migration: column created_at not in whitelist' ) ;
}
db . exec ( "ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))" ) ;
console . log ( '[migration] sessions.created_at column added' ) ;
}
}
2026-05-14 01:17:05 -05:00
} ,
{
version : 'v0.44' ,
description : 'performance: add missing indexes for frequently queried columns' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)' ) ;
}
} ,
{
version : 'v0.45' ,
description : 'audit: add audit_log table for security event tracking' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( ` CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER ,
action TEXT NOT NULL ,
entity _type TEXT ,
entity _id INTEGER ,
details _json TEXT ,
ip _address TEXT ,
user _agent TEXT ,
created _at TEXT DEFAULT ( datetime ( 'now' ) )
) ` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)' ) ;
}
} ,
{
version : 'v0.46' ,
description : 'billing: add cycle_type and cycle_day columns to bills' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'cycle_type' ) && cols . includes ( 'cycle_day' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'cycle_type' ) ) {
db . exec ( ` ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly' ` ) ;
}
if ( ! cols . includes ( 'cycle_day' ) ) {
db . exec ( ` ALTER TABLE bills ADD COLUMN cycle_day TEXT ` ) ;
}
}
} ,
{
version : 'v0.47' ,
description : 'settings: reset backup_schedule_retention_count default from 14 to 2' ,
check : function ( ) {
const row = db . prepare ( "SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'" ) . get ( ) ;
return ! row || row . value !== '14' ;
} ,
run : function ( ) {
db . prepare ( "UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'" ) . run ( ) ;
console . log ( '[migration] backup_schedule_retention_count updated from 14 to 2' ) ;
}
2026-05-09 18:25:25 -05:00
}
] ;
2026-05-14 01:17:05 -05:00
2026-05-09 18:25:25 -05:00
// Check for legacy notification columns
const userCols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const newUserCols = [
'active' , 'is_default_admin' , 'notification_email' , 'notifications_enabled' ,
'notify_3d' , 'notify_1d' , 'notify_due' , 'notify_overdue'
] ;
const hasNotificationColumns = newUserCols . every ( col => userCols . includes ( col ) ) ;
// If notification columns exist, mark that migration as applied
if ( hasNotificationColumns ) {
try {
recordMigration ( 'legacy-notification-columns' , 'users: notification columns' ) ;
console . log ( '[migration] Recorded legacy notification columns migration' ) ;
} catch ( e ) {
// Ignore if already recorded
}
}
// Process all versioned migrations
for ( const migration of migrations ) {
if ( migration . check ( ) ) {
try {
recordMigration ( migration . version , migration . description ) ;
console . log ( ` [migration] Recorded legacy migration ${ migration . version } : ${ migration . description } ` ) ;
} catch ( e ) {
// Ignore if already recorded
}
2026-05-09 19:47:00 -05:00
} else {
// Migration changes are NOT present - run the migration to apply them
try {
console . log ( ` [migration] Running legacy migration ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 22:34:50 -05:00
// Wrap legacy migration in transaction
db . exec ( 'BEGIN' ) ;
console . log ( ` [migration] Transaction BEGIN for legacy ${ migration . version } ` ) ;
2026-05-09 19:47:00 -05:00
migration . run ( ) ;
recordMigration ( migration . version , migration . description ) ;
2026-05-09 22:34:50 -05:00
db . exec ( 'COMMIT' ) ;
console . log ( ` [migration] Transaction COMMIT for legacy ${ migration . version } ` ) ;
2026-05-09 19:47:00 -05:00
} catch ( err ) {
2026-05-09 22:34:50 -05:00
db . exec ( 'ROLLBACK' ) ;
console . error ( ` [migration-error] Failed to apply legacy migration ${ migration . version } : ${ err . message } . Rolled back. ` ) ;
2026-05-09 19:47:00 -05:00
throw err ;
}
2026-05-09 18:25:25 -05:00
}
}
console . log ( '[migration] Legacy database reconciliation complete' ) ;
}
2026-05-09 15:17:40 -05:00
function recordMigration ( version , description ) {
const stmt = db . prepare ( 'INSERT INTO schema_migrations (version, description) VALUES (?, ?)' ) ;
stmt . run ( version , description ) ;
console . log ( ` [migration] Applied ${ version } : ${ description } ` ) ;
}
2026-05-09 23:24:51 -05:00
function validateMigrationDependencies ( migration , appliedVersions ) {
// Validate that all dependencies for a migration have been applied
const deps = migration . dependsOn || [ ] ;
const missing = deps . filter ( dep => ! appliedVersions . has ( dep ) ) ;
if ( missing . length === 0 ) {
return { valid : true } ;
}
return { valid : false , missing } ;
}
2026-05-03 19:51:57 -05:00
function runMigrations ( ) {
2026-05-10 09:45:39 -05:00
console . log ( '[migration] Starting database migrations' ) ;
const startTime = Date . now ( ) ;
// Log start of migrations to audit log
try {
getLogAudit ( ) ( {
action : 'migration.start' ,
entity _type : 'migration' ,
entity _id : null ,
details : { message : 'Starting database migrations' }
} ) ;
} catch ( auditErr ) {
console . error ( ` [audit-error] Failed to log migration start to audit log: ${ auditErr . message } ` ) ;
}
2026-05-09 23:24:51 -05:00
// Define all migrations with explicit version tracking and dependency chains
2026-05-09 15:17:40 -05:00
const migrations = [
{
version : 'v0.2' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ ] ,
2026-05-09 15:17:40 -05:00
description : 'payments: soft-delete column' ,
run : function ( ) {
const paymentCols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! paymentCols . includes ( 'deleted_at' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN deleted_at TEXT' ) ;
// Index for fast filtering of live payments
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)' ) ;
console . log ( '[migration] payments.deleted_at column added' ) ;
}
}
} ,
{
version : 'v0.3' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.2' ] ,
2026-05-09 15:17:40 -05:00
description : 'payments: compound index for tracker query' ,
run : function ( ) {
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)' ) ;
}
} ,
{
version : 'v0.4' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.3' ] ,
2026-05-09 15:17:40 -05:00
description : 'monthly_bill_state: per-bill per-month overrides' ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS monthly _bill _state (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL CHECK ( year BETWEEN 2000 AND 2100 ) ,
month INTEGER NOT NULL CHECK ( month BETWEEN 1 AND 12 ) ,
actual _amount REAL ,
notes TEXT ,
is _skipped INTEGER NOT NULL DEFAULT 0 ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( bill _id , year , month )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)' ) ;
console . log ( '[migration] monthly_bill_state table ensured' ) ;
}
} ,
{
version : 'v0.13' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.4' ] ,
2026-05-09 15:17:40 -05:00
description : 'users: profile columns' ,
run : function ( ) {
const userColsNow = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const profileCols = [
[ 'display_name' , 'TEXT' ] ,
[ 'last_password_change_at' , 'TEXT' ] ,
] ;
for ( const [ col , def ] of profileCols ) {
if ( ! userColsNow . includes ( col ) ) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if ( ! isValidColumnName ( col ) || ! isValidSqlDefinition ( def ) ) {
throw new Error ( ` Invalid migration: column ' ${ col } ' not in whitelist ` ) ;
}
db . exec ( ` ALTER TABLE users ADD COLUMN ${ col } ${ def } ` ) ;
}
}
}
} ,
{
version : 'v0.14' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.13' ] ,
2026-05-09 15:17:40 -05:00
description : 'bills: history visibility mode' ,
run : function ( ) {
const billColsHist = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billColsHist . includes ( 'history_visibility' ) ) {
db . exec ( "ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'" ) ;
console . log ( '[migration] bills.history_visibility column added' ) ;
}
}
} ,
{
version : 'v0.14.4' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.14' ] ,
2026-05-09 15:17:40 -05:00
description : 'bills: optional credit-card APR / interest rate' ,
run : function ( ) {
const billColsInterest = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billColsInterest . includes ( 'interest_rate' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN interest_rate REAL' ) ;
console . log ( '[migration] bills.interest_rate column added' ) ;
}
}
} ,
{
version : 'v0.15' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.14.4' ] ,
2026-05-09 15:17:40 -05:00
description : 'import_sessions and import_history tables' ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS import _sessions (
id TEXT PRIMARY KEY ,
user _id INTEGER NOT NULL ,
created _at TEXT NOT NULL ,
expires _at TEXT NOT NULL ,
preview _json TEXT NOT NULL
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)' ) ;
// ── import_history: per-user audit log (v0.38) ────────────────────────────
db . exec ( `
CREATE TABLE IF NOT EXISTS import _history (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL ,
imported _at TEXT NOT NULL ,
source _filename TEXT ,
file _type TEXT DEFAULT 'xlsx' ,
sheet _name TEXT ,
rows _parsed INTEGER DEFAULT 0 ,
rows _created INTEGER DEFAULT 0 ,
rows _updated INTEGER DEFAULT 0 ,
rows _skipped INTEGER DEFAULT 0 ,
rows _ambiguous INTEGER DEFAULT 0 ,
rows _errored INTEGER DEFAULT 0 ,
options _json TEXT ,
summary _json TEXT
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)' ) ;
}
} ,
{
version : 'v0.17' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.15' ] ,
2026-05-09 15:17:40 -05:00
description : 'users: external identity / OIDC columns' ,
run : function ( ) {
const userColsOidc = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const oidcUserCols = [
[ 'auth_provider' , "TEXT NOT NULL DEFAULT 'local'" ] ,
[ 'external_subject' , 'TEXT' ] ,
[ 'email' , 'TEXT' ] ,
[ 'last_login_at' , 'TEXT' ] ,
] ;
for ( const [ col , def ] of oidcUserCols ) {
if ( ! userColsOidc . includes ( col ) ) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if ( ! isValidColumnName ( col ) || ! isValidSqlDefinition ( def ) ) {
throw new Error ( ` Invalid migration: column ' ${ col } ' not in whitelist ` ) ;
}
db . exec ( ` ALTER TABLE users ADD COLUMN ${ col } ${ def } ` ) ;
}
}
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
db . exec ( `
CREATE TABLE IF NOT EXISTS oidc _states (
id TEXT PRIMARY KEY ,
nonce TEXT NOT NULL ,
code _verifier TEXT NOT NULL ,
redirect _to TEXT ,
created _at TEXT NOT NULL ,
expires _at TEXT NOT NULL
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)' ) ;
}
} ,
{
version : 'v0.18.1' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.17' ] ,
2026-05-09 15:17:40 -05:00
description : 'monthly_income: per-user monthly income for Summary planning' ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS monthly _income (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL CHECK ( year BETWEEN 2000 AND 2100 ) ,
month INTEGER NOT NULL CHECK ( month BETWEEN 1 AND 12 ) ,
label TEXT NOT NULL DEFAULT 'Salary' ,
amount REAL NOT NULL DEFAULT 0 ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , year , month )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)' ) ;
}
} ,
{
version : 'v0.18.2' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.18.1' ] ,
2026-05-09 15:17:40 -05:00
description : 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th' ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS monthly _starting _amounts (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL CHECK ( year BETWEEN 2000 AND 2100 ) ,
month INTEGER NOT NULL CHECK ( month BETWEEN 1 AND 12 ) ,
first _amount REAL NOT NULL DEFAULT 0 CHECK ( first _amount >= 0 ) ,
fifteenth _amount REAL NOT NULL DEFAULT 0 CHECK ( fifteenth _amount >= 0 ) ,
other _amount REAL NOT NULL DEFAULT 0 CHECK ( other _amount >= 0 ) ,
notes TEXT ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , year , month )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)' ) ;
}
} ,
{
version : 'v0.18.3' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.18.2' ] ,
2026-05-09 15:17:40 -05:00
description : 'monthly_starting_amounts: add other_amount column' ,
run : function ( ) {
const startingCols = db . prepare ( 'PRAGMA table_info(monthly_starting_amounts)' ) . all ( ) . map ( c => c . name ) ;
if ( ! startingCols . includes ( 'other_amount' ) ) {
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
if ( ! isValidColumnName ( 'other_amount' ) ) {
throw new Error ( 'Invalid migration: column other_amount not in whitelist' ) ;
}
db . exec ( 'ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)' ) ;
console . log ( '[migration] monthly_starting_amounts.other_amount column added' ) ;
}
}
} ,
{
version : 'v0.38' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.18.3' ] ,
2026-05-09 15:17:40 -05:00
description : 'import_history: per-user audit log' ,
run : function ( ) {
// This was already handled in v0.15, but keeping for completeness
}
} ,
{
version : 'v0.40' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.38' ] ,
2026-05-09 15:17:40 -05:00
description : 'ownership: user-scoped bills/categories' ,
run : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'user_id' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE' ) ;
}
const categoryCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! categoryCols . includes ( 'user_id' ) ) {
db . exec ( 'ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE' ) ;
}
const categorySql = db . prepare ( "SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'" ) . get ( ) ? . sql || '' ;
if ( /name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i . test ( categorySql ) ) {
db . exec ( 'PRAGMA foreign_keys = OFF' ) ;
db . exec ( `
CREATE TABLE IF NOT EXISTS categories _v040 (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER REFERENCES users ( id ) ON DELETE CASCADE ,
name TEXT NOT NULL ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) )
)
` );
db . exec ( 'INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories' ) ;
db . exec ( 'DROP TABLE categories' ) ;
db . exec ( 'ALTER TABLE categories_v040 RENAME TO categories' ) ;
db . exec ( 'PRAGMA foreign_keys = ON' ) ;
}
2026-05-09 19:47:00 -05:00
const firstAdmin = db . prepare ( "SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" ) . get ( ) ;
if ( firstAdmin ) {
db . prepare ( 'UPDATE bills SET user_id = ? WHERE user_id IS NULL' ) . run ( firstAdmin . id ) ;
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
2026-05-09 15:17:40 -05:00
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
db . prepare ( `
DELETE FROM categories
WHERE user _id IS NULL
AND LOWER ( name ) IN (
SELECT LOWER ( name ) FROM categories WHERE user _id = ?
)
2026-05-09 19:47:00 -05:00
` ).run(firstAdmin.id);
db . prepare ( 'UPDATE categories SET user_id = ? WHERE user_id IS NULL' ) . run ( firstAdmin . id ) ;
2026-05-09 15:17:40 -05:00
}
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)' ) ;
db . exec ( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)' ) ;
}
} ,
{
version : 'v0.41' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.40' ] ,
2026-05-09 15:17:40 -05:00
description : 'bills and categories: is_seeded flag for demo data cleanup' ,
run : function ( ) {
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
const billColsSeeded = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billColsSeeded . includes ( 'is_seeded' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[migration] bills.is_seeded column added' ) ;
}
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
const categoryColsSeeded = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! categoryColsSeeded . includes ( 'is_seeded' ) ) {
db . exec ( 'ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[migration] categories.is_seeded column added' ) ;
}
}
2026-05-09 16:38:28 -05:00
} ,
{
version : 'v0.42' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.41' ] ,
2026-05-09 16:38:28 -05:00
description : 'bill_history_ranges: per-bill date ranges for history visibility' ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS bill _history _ranges (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
start _year INTEGER NOT NULL ,
start _month INTEGER NOT NULL ,
end _year INTEGER ,
end _month INTEGER ,
label TEXT ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)' ) ;
}
2026-05-09 20:19:46 -05:00
} ,
{
version : 'v0.43' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.42' ] ,
2026-05-09 20:19:46 -05:00
description : 'sessions: add created_at column' ,
run : function ( ) {
const sessionCols = db . prepare ( 'PRAGMA table_info(sessions)' ) . all ( ) . map ( c => c . name ) ;
if ( ! sessionCols . includes ( 'created_at' ) ) {
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
if ( ! isValidColumnName ( 'created_at' ) ) {
throw new Error ( 'Invalid migration: column created_at not in whitelist' ) ;
}
db . exec ( "ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))" ) ;
console . log ( '[migration] sessions.created_at column added' ) ;
}
}
2026-05-09 22:44:38 -05:00
} ,
{
version : 'v0.44' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.43' ] ,
2026-05-09 22:44:38 -05:00
description : 'performance: add missing indexes for frequently queried columns' ,
run : function ( ) {
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)' ) ;
console . log ( '[migration] Added indexes for frequently queried columns' ) ;
}
2026-05-10 00:03:12 -05:00
} ,
{
version : 'v0.45' ,
dependsOn : [ 'v0.44' ] ,
description : 'audit: add audit_log table for security event tracking' ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS audit _log (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER ,
action TEXT NOT NULL ,
entity _type TEXT ,
entity _id INTEGER ,
details _json TEXT ,
ip _address TEXT ,
user _agent TEXT ,
created _at TEXT DEFAULT ( datetime ( 'now' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _audit _log _user ON audit _log ( user _id , created _at ) ;
CREATE INDEX IF NOT EXISTS idx _audit _log _action ON audit _log ( action , created _at ) ;
` );
}
2026-05-10 00:39:11 -05:00
} ,
{
version : 'v0.46' ,
description : 'billing: add cycle_type and cycle_day columns to bills' ,
dependsOn : [ 'v0.45' ] ,
run : function ( ) {
2026-05-14 01:17:05 -05:00
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'cycle_type' ) ) {
db . exec ( ` ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly' ` ) ;
}
if ( ! cols . includes ( 'cycle_day' ) ) {
db . exec ( ` ALTER TABLE bills ADD COLUMN cycle_day TEXT ` ) ;
}
}
} ,
{
version : 'v0.47' ,
description : 'settings: reset backup_schedule_retention_count default from 14 to 2' ,
dependsOn : [ 'v0.46' ] ,
run : function ( ) {
db . prepare ( "UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'" ) . run ( ) ;
console . log ( '[migration] backup_schedule_retention_count updated from 14 to 2' ) ;
2026-05-10 00:39:11 -05:00
}
2026-05-09 15:17:40 -05:00
}
] ;
2026-05-14 01:17:05 -05:00
2026-05-03 19:51:57 -05:00
// ── users: notification columns ───────────────────────────────────────────
2026-05-09 15:17:40 -05:00
// This migration needs to run first since it's not versioned in the schema
2026-05-10 09:45:39 -05:00
console . log ( '[migration] Applying unversioned user notification columns' ) ;
const unversionedStartTime = Date . now ( ) ;
2026-05-09 22:34:50 -05:00
try {
db . exec ( 'BEGIN' ) ;
console . log ( '[migration] Transaction BEGIN for unversioned user notification columns' ) ;
const userCols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const newUserCols = [
[ 'active' , 'INTEGER NOT NULL DEFAULT 1' ] ,
[ 'is_default_admin' , 'INTEGER NOT NULL DEFAULT 0' ] ,
[ 'notification_email' , 'TEXT' ] ,
[ 'notifications_enabled' , 'INTEGER NOT NULL DEFAULT 0' ] ,
[ 'notify_3d' , 'INTEGER NOT NULL DEFAULT 1' ] ,
[ 'notify_1d' , 'INTEGER NOT NULL DEFAULT 1' ] ,
[ 'notify_due' , 'INTEGER NOT NULL DEFAULT 1' ] ,
[ 'notify_overdue' , 'INTEGER NOT NULL DEFAULT 1' ] ,
] ;
for ( const [ col , def ] of newUserCols ) {
if ( ! userCols . includes ( col ) ) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if ( ! isValidColumnName ( col ) || ! isValidSqlDefinition ( def ) ) {
throw new Error ( ` Invalid migration: column ' ${ col } ' not in whitelist ` ) ;
}
db . exec ( ` ALTER TABLE users ADD COLUMN ${ col } ${ def } ` ) ;
2026-05-09 13:03:36 -05:00
}
}
2026-05-09 22:34:50 -05:00
const defaultAdminName = process . env . INIT _ADMIN _USER || 'admin' ;
db . prepare ( `
UPDATE users
SET is _default _admin = 1
2026-05-04 23:34:24 -05:00
WHERE role = 'admin'
2026-05-09 22:34:50 -05:00
AND username = ?
AND NOT EXISTS ( SELECT 1 FROM users WHERE is _default _admin = 1 )
` ).run(defaultAdminName);
db . exec ( `
UPDATE users
SET is _default _admin = 1
WHERE id = (
SELECT id FROM users
WHERE role = 'admin'
ORDER BY id
LIMIT 1
)
AND NOT EXISTS ( SELECT 1 FROM users WHERE is _default _admin = 1 )
` );
db . exec ( 'COMMIT' ) ;
console . log ( '[migration] Transaction COMMIT for unversioned user notification columns' ) ;
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - unversionedStartTime ;
console . log ( ` [migration] Unversioned user notification columns completed in ${ elapsed } ms ` ) ;
2026-05-09 22:34:50 -05:00
} catch ( err ) {
db . exec ( 'ROLLBACK' ) ;
2026-05-10 09:45:39 -05:00
const elapsed = Date . now ( ) - unversionedStartTime ;
console . error ( ` [migration-error] Failed to apply unversioned user notification columns after ${ elapsed } ms: ${ err . message } . Rolled back. ` ) ;
// Log migration failure to audit log (only safe after initSchema completes)
try {
getLogAudit ( ) ( {
action : 'migration.failure' ,
entity _type : 'migration' ,
entity _id : null ,
details : {
version : 'unversioned-user-notification-columns' ,
description : 'users: notification columns' ,
error : err . message ,
elapsed _ms : elapsed
}
} ) ;
} catch ( auditErr ) {
console . error ( ` [audit-error] Failed to log migration failure to audit log: ${ auditErr . message } ` ) ;
}
2026-05-09 22:34:50 -05:00
throw err ;
}
2026-05-09 15:17:40 -05:00
2026-05-09 23:24:51 -05:00
// Build set of already-applied versions for dependency checking
const appliedVersions = new Set (
db . prepare ( 'SELECT version FROM schema_migrations' ) . all ( ) . map ( r => r . version )
) ;
2026-05-09 15:17:40 -05:00
// Process all versioned migrations
for ( const migration of migrations ) {
if ( ! hasMigrationBeenApplied ( migration . version ) ) {
2026-05-09 23:24:51 -05:00
// Validate dependencies before applying
const depCheck = validateMigrationDependencies ( migration , appliedVersions ) ;
2026-05-10 09:45:39 -05:00
console . log ( ` [migration] Applying ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 23:24:51 -05:00
if ( ! depCheck . valid ) {
console . error ( ` [migration-error] ${ migration . version } depends on [ ${ depCheck . missing . join ( ', ' ) } ] which have not been applied. Skipping. ` ) ;
continue ;
}
2026-05-09 15:17:40 -05:00
console . log ( ` [migration] Applying ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 23:24:51 -05:00
if ( migration . dependsOn && migration . dependsOn . length > 0 ) {
console . log ( ` [migration] ${ migration . version } depends on [ ${ migration . dependsOn . join ( ', ' ) } ] — satisfied ` ) ;
}
2026-05-10 09:45:39 -05:00
// Timing for migration execution
const migrationStartTime = Date . now ( ) ;
2026-05-09 15:17:40 -05:00
try {
2026-05-09 22:34:50 -05:00
// Special handling for v0.40 migration which uses PRAGMA statements
if ( migration . version === 'v0.40' ) {
// PRAGMA foreign_keys cannot run inside a transaction, so we
// disable FK checks before BEGIN and re-enable in a finally block
// to guarantee FK is always restored even on failure.
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const categoryCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
const needsForeignKeyOff = ! billCols . includes ( 'user_id' ) || ! categoryCols . includes ( 'user_id' ) ;
if ( needsForeignKeyOff ) {
db . exec ( 'PRAGMA foreign_keys = OFF' ) ;
}
try {
db . exec ( 'BEGIN' ) ;
console . log ( ` [migration] Transaction BEGIN for ${ migration . version } ` ) ;
migration . run ( ) ;
recordMigration ( migration . version , migration . description ) ;
db . exec ( 'COMMIT' ) ;
console . log ( ` [migration] Transaction COMMIT for ${ migration . version } ` ) ;
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - migrationStartTime ;
console . log ( ` [migration] ${ migration . version } completed in ${ elapsed } ms ` ) ;
2026-05-09 23:24:51 -05:00
appliedVersions . add ( migration . version ) ;
2026-05-09 22:34:50 -05:00
} catch ( innerErr ) {
db . exec ( 'ROLLBACK' ) ;
2026-05-10 09:45:39 -05:00
const elapsed = Date . now ( ) - migrationStartTime ;
console . error ( ` [migration-error] ${ migration . version } failed after ${ elapsed } ms: ${ innerErr . message } . Rolled back. ` ) ;
// Log migration failure to audit log (only safe after initSchema completes)
try {
getLogAudit ( ) ( {
action : 'migration.failure' ,
entity _type : 'migration' ,
entity _id : null ,
details : {
version : migration . version ,
description : migration . description ,
error : innerErr . message ,
elapsed _ms : elapsed
}
} ) ;
} catch ( auditErr ) {
console . error ( ` [audit-error] Failed to log migration failure to audit log: ${ auditErr . message } ` ) ;
}
2026-05-09 22:34:50 -05:00
throw innerErr ;
} finally {
// Always restore FK checks — even on failure path
if ( needsForeignKeyOff ) {
db . exec ( 'PRAGMA foreign_keys = ON' ) ;
}
}
} else {
// Standard transaction wrapping for other migrations
db . exec ( 'BEGIN' ) ;
console . log ( ` [migration] Transaction BEGIN for ${ migration . version } ` ) ;
migration . run ( ) ;
recordMigration ( migration . version , migration . description ) ;
db . exec ( 'COMMIT' ) ;
console . log ( ` [migration] Transaction COMMIT for ${ migration . version } ` ) ;
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - migrationStartTime ;
console . log ( ` [migration] ${ migration . version } completed in ${ elapsed } ms ` ) ;
2026-05-09 23:24:51 -05:00
appliedVersions . add ( migration . version ) ;
2026-05-09 22:34:50 -05:00
}
2026-05-09 15:17:40 -05:00
} catch ( err ) {
2026-05-09 22:34:50 -05:00
db . exec ( 'ROLLBACK' ) ;
2026-05-10 09:45:39 -05:00
const elapsed = Date . now ( ) - migrationStartTime ;
console . error ( ` [migration-error] Failed to apply ${ migration . version } after ${ elapsed } ms: ${ err . message } . Rolled back. ` ) ;
// Log migration failure to audit log (only safe after initSchema completes)
try {
getLogAudit ( ) ( {
action : 'migration.failure' ,
entity _type : 'migration' ,
entity _id : null ,
details : {
version : migration . version ,
description : migration . description ,
error : err . message ,
elapsed _ms : elapsed
}
} ) ;
} catch ( auditErr ) {
console . error ( ` [audit-error] Failed to log migration failure to audit log: ${ auditErr . message } ` ) ;
}
2026-05-09 15:17:40 -05:00
throw err ;
2026-05-09 13:03:36 -05:00
}
2026-05-09 15:17:40 -05:00
} else {
console . log ( ` [migration] Skipping already applied ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 13:03:36 -05:00
}
2026-05-03 19:51:57 -05:00
}
2026-05-09 15:17:40 -05:00
2026-05-10 09:45:39 -05:00
// Log total migration time
// Log completion of all migrations to audit log
try {
getLogAudit ( ) ( {
action : 'migration.complete' ,
entity _type : 'migration' ,
entity _id : null ,
details : {
2026-05-10 10:44:39 -05:00
total _time _ms : Date . now ( ) - startTime ,
2026-05-10 09:45:39 -05:00
message : 'All migrations completed successfully'
2026-05-10 10:44:39 -05:00
}
} ) ;
} catch ( auditErr ) {
2026-05-10 09:45:39 -05:00
console . error ( ` [audit-error] Failed to log migration completion to audit log: ${ auditErr . message } ` ) ;
}
const totalTime = Date . now ( ) - startTime ;
console . log ( ` [migration] All migrations completed in ${ totalTime } ms ` ) ;
2026-05-09 16:38:28 -05:00
// All migrations are now versioned
2026-05-03 19:51:57 -05:00
}
function seedDefaults ( ) {
const defaults = [
[ 'currency' , 'USD' ] ,
[ 'date_format' , 'MM/DD/YYYY' ] ,
[ 'grace_period_days' , '5' ] ,
[ 'notify_days_before' , '3' ] ,
[ 'backup_enabled' , 'false' ] ,
[ 'backup_frequency_days' , '1' ] ,
[ 'backup_keep_count' , '14' ] ,
[ 'backup_path' , process . env . BACKUP _PATH || path . join ( _ _dirname , '..' , 'backups' ) ] ,
[ 'backup_schedule_enabled' , 'false' ] ,
[ 'backup_schedule_frequency' , 'daily' ] ,
[ 'backup_schedule_time' , '02:00' ] ,
2026-05-14 01:17:05 -05:00
[ 'backup_schedule_retention_count' , '2' ] ,
2026-05-03 19:51:57 -05:00
[ 'backup_schedule_last_run_at' , '' ] ,
[ 'backup_schedule_last_error' , '' ] ,
[ 'auth_mode' , 'multi' ] ,
[ 'default_user_id' , '' ] ,
[ 'notify_smtp_enabled' , 'false' ] ,
[ 'notify_sender_name' , 'Bill Tracker' ] ,
[ 'notify_sender_address' , '' ] ,
[ 'notify_smtp_host' , '' ] ,
[ 'notify_smtp_port' , '587' ] ,
[ 'notify_smtp_encryption' , 'starttls' ] ,
[ 'notify_smtp_self_signed' , 'false' ] ,
[ 'notify_smtp_username' , '' ] ,
[ 'notify_smtp_password' , '' ] ,
[ 'notify_allow_user_config' , 'false' ] ,
[ 'notify_global_recipient' , '' ] ,
// Cleanup worker settings (v0.15)
[ 'cleanup_import_sessions_enabled' , 'true' ] ,
[ 'cleanup_temp_exports_enabled' , 'true' ] ,
[ 'cleanup_temp_export_max_age_hours' , '2' ] ,
[ 'cleanup_backup_partials_enabled' , 'true' ] ,
[ 'cleanup_import_history_enabled' , 'false' ] ,
[ 'cleanup_import_history_max_age_days' , '365' ] ,
[ 'cleanup_last_run_at' , '' ] ,
[ 'cleanup_last_result' , '' ] ,
// Auth method settings (v0.18)
[ 'local_login_enabled' , 'true' ] ,
[ 'oidc_login_enabled' , 'false' ] ,
[ 'oidc_provider_name' , 'authentik' ] ,
[ 'oidc_issuer_url' , '' ] ,
[ 'oidc_client_id' , '' ] ,
[ 'oidc_client_secret' , '' ] ,
[ 'oidc_token_auth_method' , 'client_secret_basic' ] ,
[ 'oidc_redirect_uri' , '' ] ,
[ 'oidc_scopes' , 'openid email profile groups' ] ,
[ 'oidc_auto_provision' , 'true' ] ,
[ 'oidc_admin_group' , '' ] ,
[ 'oidc_default_role' , 'user' ] ,
] ;
const insert = db . prepare (
'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'
) ;
for ( const [ key , value ] of defaults ) {
insert . run ( key , value ) ;
}
2026-05-04 16:38:03 -05:00
// Category defaults are user-scoped. They are applied by
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
2026-05-09 13:03:36 -05:00
// ── Create initial admin user if none exists ─────────────────────────────
const userCount = db . prepare ( 'SELECT COUNT(*) as cnt FROM users' ) . get ( ) . cnt ;
if ( userCount === 0 ) {
const initUser = process . env . INIT _ADMIN _USER || 'admin' ;
const initPass = process . env . INIT _ADMIN _PASS || 'admin123' ;
// Use bcryptjs sync for database init (safe, runs once at startup)
const bcrypt = require ( 'bcryptjs' ) ;
const password _hash = bcrypt . hashSync ( initPass , 12 ) ;
db . prepare ( `
INSERT INTO users ( username , password _hash , role , is _default _admin , active , email , created _at , updated _at )
VALUES ( ? , ? , 'admin' , 1 , 1 , ? , datetime ( 'now' ) , datetime ( 'now' ) )
` ).run(initUser, password_hash, initUser + '@local');
console . log ( ` [seed] Created initial admin user: ${ initUser } ` ) ;
}
2026-05-03 19:51:57 -05:00
}
function ensureUserDefaultCategories ( userId ) {
const db = getDb ( ) ;
const insert = db . prepare ( 'INSERT INTO categories (user_id, name) VALUES (?, ?)' ) ;
for ( const name of DEFAULT _CATEGORIES ) {
const existing = db . prepare ( 'SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE' )
. get ( userId , name ) ;
if ( ! existing ) insert . run ( userId , name ) ;
}
}
function getSetting ( key ) {
const db = getDb ( ) ;
const row = db . prepare ( 'SELECT value FROM settings WHERE key = ?' ) . get ( key ) ;
return row ? row . value : null ;
}
function setSetting ( key , value ) {
const db = getDb ( ) ;
db . prepare (
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))"
) . run ( key , String ( value ) ) ;
}
function closeDb ( ) {
if ( ! db ) return ;
db . close ( ) ;
db = null ;
}
function getDbPath ( ) {
return DB _PATH ;
}
2026-05-10 10:44:39 -05:00
// Rollback SQL definitions
const ROLLBACK _SQL _MAP = {
'v0.44' : {
description : 'performance: add missing indexes for frequently queried columns' ,
sql : [
'DROP INDEX IF EXISTS idx_bills_user_name' ,
'DROP INDEX IF EXISTS idx_payments_method' ,
'DROP INDEX IF EXISTS idx_monthly_starting_amounts_user' ,
'DROP INDEX IF EXISTS idx_import_history_imported_at'
]
} ,
'v0.45' : {
description : 'audit: add audit_log table for security event tracking' ,
sql : [
'DROP INDEX IF EXISTS idx_audit_log_user' ,
'DROP INDEX IF EXISTS idx_audit_log_action' ,
'DROP TABLE IF EXISTS audit_log'
]
} ,
'v0.46' : {
description : 'billing: add cycle_type and cycle_day columns to bills' ,
sql : [
'ALTER TABLE bills DROP COLUMN cycle_day' ,
'ALTER TABLE bills DROP COLUMN cycle_type'
]
2026-05-14 01:17:05 -05:00
} ,
'v0.47' : {
description : 'settings: reset backup_schedule_retention_count default from 14 to 2' ,
sql : [
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
]
2026-05-10 10:44:39 -05:00
}
} ;
function rollbackMigration ( version ) {
if ( ! db ) throw new Error ( 'Database not initialized' ) ;
// Check the migration was actually applied
const applied = db . prepare ( 'SELECT 1 FROM schema_migrations WHERE version = ?' ) . get ( version ) ;
if ( ! applied ) {
const err = new Error ( ` Migration ${ version } has not been applied — cannot rollback ` ) ;
err . code = 'NOT_APPLIED' ;
throw err ;
}
const rollback = ROLLBACK _SQL _MAP [ version ] ;
if ( ! rollback ) {
const err = new Error ( ` Migration ${ version } does not support rollback ` ) ;
err . code = 'ROLLBACK_NOT_SUPPORTED' ;
throw err ;
}
console . log ( ` [rollback] Rolling back ${ version } : ${ rollback . description } ` ) ;
const startTime = Date . now ( ) ;
try {
db . exec ( 'BEGIN' ) ;
console . log ( ` [rollback] Transaction BEGIN for ${ version } ` ) ;
for ( const stmt of rollback . sql ) {
console . log ( ` [rollback] Executing: ${ stmt } ` ) ;
db . exec ( stmt ) ;
}
// Remove migration record
db . prepare ( 'DELETE FROM schema_migrations WHERE version = ?' ) . run ( version ) ;
console . log ( ` [rollback] Removed ${ version } from schema_migrations ` ) ;
db . exec ( 'COMMIT' ) ;
console . log ( ` [rollback] Transaction COMMIT for ${ version } ` ) ;
const elapsed = Date . now ( ) - startTime ;
console . log ( ` [rollback] ${ version } rolled back in ${ elapsed } ms ` ) ;
// Audit log
try {
getLogAudit ( ) ( {
action : 'migration.rollback' ,
entity _type : 'migration' ,
entity _id : null ,
details : { version , description : rollback . description , elapsed _ms : elapsed }
} ) ;
} catch ( auditErr ) {
console . error ( ` [audit-error] Failed to log rollback to audit log: ${ auditErr . message } ` ) ;
}
return { success : true , version , description : rollback . description , elapsed _ms : elapsed } ;
} catch ( err ) {
db . exec ( 'ROLLBACK' ) ;
const elapsed = Date . now ( ) - startTime ;
console . error ( ` [rollback-error] ${ version } failed after ${ elapsed } ms: ${ err . message } ` ) ;
// Audit log
try {
getLogAudit ( ) ( {
action : 'migration.rollback.failure' ,
entity _type : 'migration' ,
entity _id : null ,
details : { version , description : rollback . description , error : err . message , elapsed _ms : elapsed }
} ) ;
} catch ( auditErr ) {
console . error ( ` [audit-error] Failed to log rollback failure to audit log: ${ auditErr . message } ` ) ;
}
throw err ;
}
}
2026-05-09 20:19:46 -05:00
/ * *
* Cleanup expired sessions from the database
* @ returns { Object } Result object with changes count
* /
function cleanupExpiredSessions ( ) {
const result = db . prepare ( "DELETE FROM sessions WHERE expires_at < datetime('now')" ) . run ( ) ;
console . log ( ` [cleanup] Purged ${ result . changes } expired sessions ` ) ;
return result ;
}
2026-05-10 10:44:39 -05:00
module . exports = { getDb , getSetting , setSetting , closeDb , getDbPath , ensureUserDefaultCategories , cleanupExpiredSessions , rollbackMigration } ;