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' ,
2026-05-16 10:56:56 -05:00
'Food' ,
2026-05-04 16:38:03 -05:00
'Loans' ,
'Insurance' ,
2026-05-16 10:56:56 -05:00
'Beauty' ,
'Entertainment' ,
2026-05-04 16:38:03 -05:00
'Subscriptions' ,
2026-05-16 10:56:56 -05:00
'Pets' ,
2026-05-04 16:38:03 -05:00
'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' ,
2026-05-30 14:33:55 -05:00
'notify_3d' , 'notify_1d' , 'notify_due' , 'notify_overdue' , 'notify_amount_change' ,
2026-05-09 13:03:36 -05:00
'display_name' , 'last_password_change_at' , 'auth_provider' , 'external_subject' ,
'email' , 'last_login_at' ,
// payments table columns
2026-05-16 20:26:09 -05:00
'deleted_at' , 'payment_source' , 'transaction_id' ,
2026-05-09 13:03:36 -05:00
// monthly_starting_amounts table columns
'other_amount' ,
// bills table columns
'history_visibility' , 'interest_rate' , 'user_id' ,
2026-05-14 02:11:54 -05:00
'current_balance' , 'minimum_payment' , 'snowball_order' , 'snowball_include' ,
2026-05-30 16:13:37 -05:00
'sort_order' , 'snowball_exempt' , 'is_subscription' , 'subscription_type' , 'reminder_days_before' ,
2026-05-30 14:33:55 -05:00
'subscription_source' , 'subscription_detected_at' , 'deleted_at' , 'drift_snoozed_until' ,
2026-05-30 20:04:50 -05:00
// categories table columns
'sort_order' ,
2026-05-09 20:19:46 -05:00
// sessions table columns
'created_at' ,
2026-05-29 01:51:42 -05:00
// financial_accounts table columns
'monitored' ,
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-07-03 13:05:16 -05:00
// Subscription catalog seed rows live in their own module (pure data, no logic).
const { SUBSCRIPTION _CATALOG _ROWS , SUBSCRIPTION _CATALOG _V2 _ROWS } = require ( './subscriptionCatalogSeed' ) ;
2026-05-29 18:34:50 -05:00
function runSubscriptionCatalogV2Migration ( database ) {
// Category fixes for existing rows
database . prepare ( `
UPDATE subscription _catalog SET subscription _type = 'software'
WHERE name = 'Discord Nitro' AND subscription _type = 'news'
` ).run();
database . prepare ( `
UPDATE subscription _catalog SET subscription _type = 'streaming'
WHERE name = 'Twitch Turbo' AND subscription _type = 'news'
` ).run();
database . prepare ( `
UPDATE subscription _catalog SET subscription _type = 'software'
WHERE name = 'X Premium' AND subscription _type = 'news'
` ).run();
// New entries — skip any name already in the catalog
const existing = new Set (
database . prepare ( 'SELECT name FROM subscription_catalog' ) . all ( ) . map ( r => r . name )
) ;
const toInsert = SUBSCRIPTION _CATALOG _V2 _ROWS . filter ( r => ! existing . has ( r [ 1 ] ) ) ;
if ( toInsert . length > 0 ) {
const insert = database . prepare (
'INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) VALUES (?,?,?,?,?,?)'
) ;
const insertMany = database . transaction ( ( rows ) => {
for ( const row of rows ) insert . run ( ... row ) ;
} ) ;
insertMany ( toInsert ) ;
console . log ( ` [migration] subscription_catalog v2: added ${ toInsert . length } new entries ` ) ;
}
}
2026-05-29 18:06:12 -05:00
function runAdvisoryFiltersMigration ( database ) {
database . exec ( `
CREATE TABLE IF NOT EXISTS advisory _non _bill _filters (
id TEXT PRIMARY KEY ,
pattern TEXT NOT NULL ,
confidence TEXT NOT NULL CHECK ( confidence IN ( 'high' , 'medium' ) ) ,
category TEXT NOT NULL ,
rationale TEXT
) ;
CREATE INDEX IF NOT EXISTS idx _advisory _filters _confidence
ON advisory _non _bill _filters ( confidence ) ;
CREATE TABLE IF NOT EXISTS advisory _bill _like _overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
term TEXT NOT NULL UNIQUE
) ;
` );
const filterCount = database . prepare ( 'SELECT COUNT(*) as n FROM advisory_non_bill_filters' ) . get ( ) ;
if ( filterCount . n === 0 ) {
const jsonPath = path . join ( _ _dirname , '..' , 'docs' , 'advisory_non_bill_transaction_filters_us_ms_5000.json' ) ;
const raw = fs . readFileSync ( jsonPath , 'utf8' ) ;
const data = JSON . parse ( raw ) ;
const insertFilter = database . prepare (
'INSERT INTO advisory_non_bill_filters (id, pattern, confidence, category, rationale) VALUES (?,?,?,?,?)'
) ;
const insertFilters = database . transaction ( ( rows ) => {
for ( const row of rows ) {
insertFilter . run ( row . id , row . pattern , row . confidence , row . category , row . rationale || null ) ;
}
} ) ;
insertFilters ( data . patterns || [ ] ) ;
console . log ( ` [migration] advisory_non_bill_filters: seeded ${ ( data . patterns || [ ] ) . length } rows ` ) ;
const overrideTerms = data . bill _like _override _terms || [ ] ;
if ( overrideTerms . length > 0 ) {
const insertOverride = database . prepare (
'INSERT OR IGNORE INTO advisory_bill_like_overrides (term) VALUES (?)'
) ;
const insertOverrides = database . transaction ( ( terms ) => {
for ( const term of terms ) insertOverride . run ( term ) ;
} ) ;
insertOverrides ( overrideTerms ) ;
console . log ( ` [migration] advisory_bill_like_overrides: seeded ${ overrideTerms . length } rows ` ) ;
}
}
}
2026-06-14 15:15:31 -05:00
function runMerchantStoreMatchMigration ( database ) {
database . exec ( `
CREATE TABLE IF NOT EXISTS merchant _store _matches (
id TEXT PRIMARY KEY ,
entry _kind TEXT NOT NULL ,
canonical _merchant _id TEXT NOT NULL ,
canonical _name TEXT NOT NULL ,
display _name TEXT NOT NULL ,
category TEXT NOT NULL ,
merchant _type TEXT ,
scope TEXT NOT NULL ,
priority INTEGER NOT NULL DEFAULT 0 ,
match _patterns TEXT NOT NULL ,
negative _patterns TEXT ,
locality _city TEXT ,
locality _state TEXT
) ;
CREATE INDEX IF NOT EXISTS idx _merchant _store _matches _canonical
ON merchant _store _matches ( canonical _merchant _id ) ;
` );
const count = database . prepare ( 'SELECT COUNT(*) as n FROM merchant_store_matches' ) . get ( ) ;
if ( count . n === 0 ) {
const jsonPath = path . join ( _ _dirname , '..' , 'docs' , 'merchant_store_match_us_nems_online_5k_v0_2.json' ) ;
const raw = fs . readFileSync ( jsonPath , 'utf8' ) ;
const data = JSON . parse ( raw ) ;
const insertEntry = database . prepare ( `
INSERT INTO merchant _store _matches
( id , entry _kind , canonical _merchant _id , canonical _name , display _name , category ,
merchant _type , scope , priority , match _patterns , negative _patterns ,
locality _city , locality _state )
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? )
` );
const insertEntries = database . transaction ( ( rows ) => {
for ( const row of rows ) {
insertEntry . run (
row . id ,
row . entry _kind ,
row . canonical _merchant _id ,
row . canonical _name ,
row . display _name ,
row . category ,
row . merchant _type || null ,
row . scope ,
row . priority || 0 ,
JSON . stringify ( row . match _patterns || [ ] ) ,
JSON . stringify ( row . negative _patterns || [ ] ) ,
row . locality ? . city || null ,
row . locality ? . state || null ,
) ;
}
} ) ;
insertEntries ( data . merchant _store _entries || [ ] ) ;
console . log ( ` [migration] merchant_store_matches: seeded ${ ( data . merchant _store _entries || [ ] ) . length } rows ` ) ;
}
}
2026-05-29 01:51:42 -05:00
function runSubscriptionCatalogMigration ( database ) {
database . exec ( `
CREATE TABLE IF NOT EXISTS subscription _catalog (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
rank INTEGER NOT NULL ,
name TEXT NOT NULL ,
category TEXT NOT NULL ,
subscription _type TEXT NOT NULL ,
website TEXT ,
domain TEXT ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _subscription _catalog _rank ON subscription _catalog ( rank ) ;
CREATE INDEX IF NOT EXISTS idx _subscription _catalog _type ON subscription _catalog ( subscription _type ) ;
` );
const existing = database . prepare ( 'SELECT COUNT(*) as n FROM subscription_catalog' ) . get ( ) ;
if ( existing . n === 0 ) {
const insert = database . prepare (
'INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) VALUES (?,?,?,?,?,?)'
) ;
const insertMany = database . transaction ( ( rows ) => {
for ( const row of rows ) insert . run ( ... row ) ;
} ) ;
insertMany ( SUBSCRIPTION _CATALOG _ROWS ) ;
console . log ( ` [migration] subscription_catalog: seeded ${ SUBSCRIPTION _CATALOG _ROWS . length } rows ` ) ;
}
}
2026-05-16 20:26:09 -05:00
function seedManualDataSources ( database = db ) {
if ( ! database ) return ;
const hasDataSources = database . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='data_sources'" ) . get ( ) ;
const hasUsers = database . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" ) . get ( ) ;
if ( ! hasDataSources || ! hasUsers ) return ;
database . exec ( `
INSERT INTO data _sources ( user _id , type , provider , name , status )
SELECT u . id , 'manual' , 'manual' , 'Manual Entry' , 'active'
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM data _sources ds
WHERE ds . user _id = u . id
AND ds . type = 'manual'
AND ds . provider = 'manual'
)
` );
}
function ensureTransactionFoundationSchema ( database = db ) {
database . exec ( `
CREATE TABLE IF NOT EXISTS data _sources (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
type TEXT NOT NULL ,
provider TEXT ,
name TEXT NOT NULL ,
status TEXT NOT NULL DEFAULT 'active' ,
config _json TEXT ,
encrypted _secret TEXT ,
last _sync _at TEXT ,
last _error TEXT ,
created _at TEXT NOT NULL DEFAULT CURRENT _TIMESTAMP ,
updated _at TEXT NOT NULL DEFAULT CURRENT _TIMESTAMP
) ;
CREATE TABLE IF NOT EXISTS financial _accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
data _source _id INTEGER ,
provider _account _id TEXT ,
name TEXT NOT NULL ,
org _name TEXT ,
account _type TEXT ,
currency TEXT ,
balance INTEGER ,
available _balance INTEGER ,
2026-05-30 21:20:51 -05:00
monitored INTEGER NOT NULL DEFAULT 1 ,
2026-05-16 20:26:09 -05:00
raw _data TEXT ,
created _at TEXT NOT NULL DEFAULT CURRENT _TIMESTAMP ,
updated _at TEXT NOT NULL DEFAULT CURRENT _TIMESTAMP ,
FOREIGN KEY ( data _source _id ) REFERENCES data _sources ( id ) ON DELETE SET NULL ,
UNIQUE ( data _source _id , provider _account _id )
) ;
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
data _source _id INTEGER ,
account _id INTEGER ,
provider _transaction _id TEXT ,
source _type TEXT NOT NULL ,
transaction _type TEXT ,
posted _date TEXT ,
transacted _at TEXT ,
amount INTEGER NOT NULL ,
currency TEXT ,
description TEXT ,
payee TEXT ,
memo TEXT ,
category TEXT ,
raw _data TEXT ,
matched _bill _id INTEGER ,
match _status TEXT NOT NULL DEFAULT 'unmatched' ,
ignored INTEGER NOT NULL DEFAULT 0 ,
created _at TEXT NOT NULL DEFAULT CURRENT _TIMESTAMP ,
updated _at TEXT NOT NULL DEFAULT CURRENT _TIMESTAMP ,
FOREIGN KEY ( data _source _id ) REFERENCES data _sources ( id ) ON DELETE SET NULL ,
FOREIGN KEY ( account _id ) REFERENCES financial _accounts ( id ) ON DELETE SET NULL ,
FOREIGN KEY ( matched _bill _id ) REFERENCES bills ( id ) ON DELETE SET NULL
) ;
CREATE INDEX IF NOT EXISTS idx _data _sources _user _type ON data _sources ( user _id , type , status ) ;
CREATE UNIQUE INDEX IF NOT EXISTS idx _data _sources _user _manual
ON data _sources ( user _id , type , provider )
WHERE type = 'manual' AND provider = 'manual' ;
CREATE INDEX IF NOT EXISTS idx _financial _accounts _user _source ON financial _accounts ( user _id , data _source _id ) ;
CREATE INDEX IF NOT EXISTS idx _transactions _user _date ON transactions ( user _id , posted _date , transacted _at ) ;
CREATE INDEX IF NOT EXISTS idx _transactions _user _match ON transactions ( user _id , match _status , ignored ) ;
CREATE INDEX IF NOT EXISTS idx _transactions _account ON transactions ( account _id ) ;
CREATE INDEX IF NOT EXISTS idx _transactions _matched _bill ON transactions ( matched _bill _id ) ;
CREATE UNIQUE INDEX IF NOT EXISTS idx _transactions _provider _dedupe
ON transactions ( data _source _id , provider _transaction _id )
WHERE provider _transaction _id IS NOT NULL ;
` );
seedManualDataSources ( database ) ;
}
2026-05-03 19:51:57 -05:00
fs . mkdirSync ( path . dirname ( DB _PATH ) , { recursive : true } ) ;
let db = null ;
let initializing = false ;
2026-06-03 22:38:33 -05:00
// Populated by runMigrations() so reconcileLegacyMigrations() can assert
// its own version list matches. Catches drift between the two arrays at startup
// on any legacy-DB upgrade path, rather than silently misconfiguring the schema.
let _runMigrationVersions = null ;
2026-05-03 19:51:57 -05:00
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 getDb ( ) {
// already ready
if ( db ) return db ;
2026-06-10 19:28:54 -05:00
// Node is single-threaded and initialization below is fully synchronous, so
// the only way to observe `initializing === true` here is a re-entrant call
// from inside init itself (e.g. a migration requiring a module that calls
// getDb() at load time). Blocking/spinning can never resolve that — it would
// deadlock the process — so fail fast with a clear message instead.
if ( initializing ) {
throw new Error ( 'getDb() called re-entrantly during database initialization' ) ;
2026-05-03 19:51:57 -05:00
}
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-14 02:11:54 -05:00
} ,
{
version : 'v0.48' ,
description : 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return [ 'current_balance' , 'minimum_payment' , 'snowball_order' , 'snowball_include' ] . every ( c => cols . includes ( c ) ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'current_balance' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN current_balance REAL' ) ;
if ( ! cols . includes ( 'minimum_payment' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN minimum_payment REAL' ) ;
if ( ! cols . includes ( 'snowball_order' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN snowball_order INTEGER' ) ;
if ( ! cols . includes ( 'snowball_include' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[migration] bills: debt snowball columns added' ) ;
}
} ,
{
version : 'v0.49' ,
description : 'users: snowball_extra_payment column' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'snowball_extra_payment' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'snowball_extra_payment' ) ) {
db . exec ( 'ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0' ) ;
}
console . log ( '[migration] users: snowball_extra_payment column added' ) ;
}
2026-05-14 02:51:29 -05:00
} ,
{
version : 'v0.50' ,
description : 'payments: balance_delta column for debt payoff tracking' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'balance_delta' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'balance_delta' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN balance_delta REAL' ) ;
}
console . log ( '[migration] payments: balance_delta column added' ) ;
}
2026-05-14 03:00:01 -05:00
} ,
{
version : 'v0.51' ,
description : 'bills: snowball_exempt column for hiding debt-like bills' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'snowball_exempt' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'snowball_exempt' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0' ) ;
}
console . log ( '[migration] bills: snowball_exempt column added' ) ;
}
2026-05-14 21:00:07 -05:00
} ,
{
version : 'v0.52' ,
description : 'users: last_seen_version for release-notes notifications' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'last_seen_version' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'last_seen_version' ) ) {
db . exec ( 'ALTER TABLE users ADD COLUMN last_seen_version TEXT' ) ;
}
console . log ( '[migration] users: last_seen_version column added' ) ;
}
2026-05-15 01:36:56 -05:00
} ,
{
version : 'v0.53' ,
description : 'user_login_history: track last 3 logins per user' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS user _login _history (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
logged _in _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
ip _address TEXT ,
2026-05-15 22:45:38 -05:00
user _agent TEXT ,
browser TEXT ,
os TEXT ,
device _type TEXT ,
device _fingerprint TEXT
2026-05-15 01:36:56 -05:00
)
` );
console . log ( '[migration] user_login_history table created' ) ;
}
2026-05-16 10:34:32 -05:00
} ,
2026-05-28 23:28:53 -05:00
{
version : 'v0.54' ,
description : 'user_settings: per-user display and billing preferences' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS user _settings (
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
key TEXT NOT NULL ,
value TEXT NOT NULL ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
PRIMARY KEY ( user _id , key )
)
` );
const userSettingKeys = [ 'currency' , 'date_format' , 'grace_period_days' , 'notify_days_before' ] ;
const users = db . prepare ( 'SELECT id FROM users' ) . all ( ) ;
const getCurrent = db . prepare ( 'SELECT value FROM settings WHERE key = ?' ) ;
const insert = db . prepare ( 'INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)' ) ;
for ( const user of users ) {
for ( const key of userSettingKeys ) {
const row = getCurrent . get ( key ) ;
if ( row ) insert . run ( user . id , key , row . value ) ;
}
}
console . log ( '[migration] user_settings table ensured' ) ;
}
} ,
{
version : 'v0.55' ,
description : 'user_login_history: parsed device metadata and fingerprint' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(user_login_history)' ) . all ( ) . map ( c => c . name ) ;
return [ 'browser' , 'os' , 'device_type' , 'device_fingerprint' ] . every ( c => cols . includes ( c ) ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(user_login_history)' ) . all ( ) . map ( c => c . name ) ;
for ( const col of [ 'browser' , 'os' , 'device_type' , 'device_fingerprint' ] ) {
if ( ! cols . includes ( col ) ) db . exec ( ` ALTER TABLE user_login_history ADD COLUMN ${ col } TEXT ` ) ;
}
console . log ( '[migration] user_login_history device metadata columns ensured' ) ;
}
} ,
2026-05-16 10:34:32 -05:00
{
version : 'v0.56' ,
description : 'bills/categories: soft-delete columns' ,
check : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const catCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
return billCols . includes ( 'deleted_at' ) && catCols . includes ( 'deleted_at' ) ;
} ,
run : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const catCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'deleted_at' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN deleted_at TEXT' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)' ) ;
}
if ( ! catCols . includes ( 'deleted_at' ) ) {
db . exec ( 'ALTER TABLE categories ADD COLUMN deleted_at TEXT' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)' ) ;
}
console . log ( '[migration] bills/categories deleted_at columns added' ) ;
}
2026-05-16 15:38:28 -05:00
} ,
{
version : 'v0.57' ,
description : 'autopay: suggestions and auto-mark paid' ,
check : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const hasDismissals = ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'" ) . get ( ) ;
return billCols . includes ( 'auto_mark_paid' ) && hasDismissals ;
} ,
run : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'auto_mark_paid' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0' ) ;
}
db . exec ( `
CREATE TABLE IF NOT EXISTS autopay _suggestion _dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
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 ) ,
dismissed _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , bill _id , year , month )
) ;
CREATE INDEX IF NOT EXISTS idx _autopay _suggestion _dismissals _user _month
ON autopay _suggestion _dismissals ( user _id , year , month ) ;
` );
console . log ( '[migration] autopay auto_mark_paid and suggestion dismissals ensured' ) ;
}
} ,
{
version : 'v0.58' ,
description : 'bills: saved bill templates' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS bill _templates (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
name TEXT NOT NULL ,
data TEXT NOT NULL ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , name )
) ;
CREATE INDEX IF NOT EXISTS idx _bill _templates _user _name
ON bill _templates ( user _id , name ) ;
` );
console . log ( '[migration] bill_templates table ensured' ) ;
}
2026-05-16 20:26:09 -05:00
} ,
{
version : 'v0.59' ,
description : 'payments: source metadata for future transaction matching' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'payment_source' ) && cols . includes ( 'transaction_id' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'payment_source' ) ) {
db . exec ( "ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'" ) ;
}
if ( ! cols . includes ( 'transaction_id' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN transaction_id INTEGER' ) ;
}
console . log ( '[migration] payments: source metadata columns added' ) ;
}
} ,
{
version : 'v0.60' ,
description : 'transactions: shared transaction foundation tables' ,
check : function ( ) {
const tables = db . prepare ( `
SELECT name
FROM sqlite _master
WHERE type = 'table'
AND name IN ( 'data_sources' , 'financial_accounts' , 'transactions' )
` ).all();
return tables . length === 3 ;
} ,
run : function ( ) {
ensureTransactionFoundationSchema ( db ) ;
console . log ( '[migration] transaction foundation tables ensured' ) ;
}
2026-05-16 21:36:04 -05:00
} ,
{
version : 'v0.61' ,
description : 'payments: one active payment per linked transaction' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_transaction_active'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE UNIQUE INDEX IF NOT EXISTS idx _payments _transaction _active
ON payments ( transaction _id )
WHERE transaction _id IS NOT NULL AND deleted _at IS NULL
` );
console . log ( '[migration] payments: transaction active unique index ensured' ) ;
}
} ,
{
version : 'v0.62' ,
description : 'matches: rejected transaction match suggestions' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='match_suggestion_rejections'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS match _suggestion _rejections (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
transaction _id INTEGER NOT NULL REFERENCES transactions ( id ) ON DELETE CASCADE ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
rejected _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , transaction _id , bill _id )
) ;
CREATE INDEX IF NOT EXISTS idx _match _suggestion _rejections _user
ON match _suggestion _rejections ( user _id , transaction _id , bill _id ) ;
` );
console . log ( '[migration] match suggestion rejections table ensured' ) ;
}
2026-05-28 22:54:07 -05:00
} ,
{
version : 'v0.63' ,
description : 'bills: subscription metadata fields' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return [ 'is_subscription' , 'subscription_type' , 'reminder_days_before' , 'subscription_source' , 'subscription_detected_at' ]
. every ( col => cols . includes ( col ) ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'is_subscription' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0' ) ;
if ( ! cols . includes ( 'subscription_type' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN subscription_type TEXT' ) ;
if ( ! cols . includes ( 'reminder_days_before' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3' ) ;
if ( ! cols . includes ( 'subscription_source' ) ) db . exec ( "ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'" ) ;
if ( ! cols . includes ( 'subscription_detected_at' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)' ) ;
console . log ( '[migration] bills: subscription metadata columns added' ) ;
}
2026-05-29 01:06:20 -05:00
} ,
{
version : 'v0.64' ,
description : 'financial_accounts: monitored flag for bill matching' ,
check : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(financial_accounts)' ) . all ( ) . map ( c => c . name ) ;
return cols . includes ( 'monitored' ) ;
} ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(financial_accounts)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'monitored' ) ) {
db . exec ( 'ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1' ) ;
console . log ( '[migration] financial_accounts: monitored column added' ) ;
}
}
2026-05-29 01:51:42 -05:00
} ,
{
version : 'v0.65' ,
description : 'subscription_catalog: top-200 known subscription services' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='subscription_catalog'" ) . get ( ) ;
} ,
run : function ( ) {
runSubscriptionCatalogMigration ( db ) ;
}
2026-05-29 02:51:30 -05:00
} ,
{
version : 'v0.66' ,
description : 'declined_subscription_hints: per-user dismissed recommendation store' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='declined_subscription_hints'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS declined _subscription _hints (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
decline _key TEXT NOT NULL ,
declined _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , decline _key )
) ;
CREATE INDEX IF NOT EXISTS idx _declined _hints _user
ON declined _subscription _hints ( user _id ) ;
` );
}
2026-05-29 03:38:48 -05:00
} ,
{
version : 'v0.67' ,
description : 'bill_merchant_rules: persistent merchant→bill auto-match rules' ,
check : function ( ) {
return ! ! db . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'" ) . get ( ) ;
} ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS bill _merchant _rules (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
merchant TEXT NOT NULL ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , bill _id , merchant )
) ;
CREATE INDEX IF NOT EXISTS idx _bill _merchant _rules _user
ON bill _merchant _rules ( user _id ) ;
` );
}
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
}
}
2026-06-03 22:38:33 -05:00
// Assert version sync with runMigrations() to catch drift between the two arrays.
// runMigrations() always runs first and populates _runMigrationVersions.
if ( _runMigrationVersions !== null ) {
const reconcileVersions = migrations . map ( m => m . version ) ;
const runSet = new Set ( _runMigrationVersions ) ;
const reconcileSet = new Set ( reconcileVersions ) ;
const onlyInRun = _runMigrationVersions . filter ( v => ! reconcileSet . has ( v ) ) ;
const onlyInReconcile = reconcileVersions . filter ( v => ! runSet . has ( v ) ) ;
if ( onlyInRun . length || onlyInReconcile . length ) {
const msg =
'[migration-sync] reconcileLegacyMigrations and runMigrations version lists are out of sync.' +
( onlyInRun . length ? ` Only in runMigrations: ${ onlyInRun . join ( ', ' ) } . ` : '' ) +
( onlyInReconcile . length ? ` Only in reconcile: ${ onlyInReconcile . join ( ', ' ) } . ` : '' ) +
' Add the missing version to both arrays.' ;
console . error ( msg ) ;
throw new Error ( msg ) ;
}
}
2026-05-09 18:25:25 -05:00
// 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-14 02:11:54 -05:00
} ,
{
version : 'v0.48' ,
description : 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)' ,
dependsOn : [ 'v0.47' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'current_balance' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN current_balance REAL' ) ;
if ( ! cols . includes ( 'minimum_payment' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN minimum_payment REAL' ) ;
if ( ! cols . includes ( 'snowball_order' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN snowball_order INTEGER' ) ;
if ( ! cols . includes ( 'snowball_include' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[migration] bills: debt snowball columns added' ) ;
}
} ,
{
version : 'v0.49' ,
description : 'users: snowball_extra_payment column' ,
dependsOn : [ 'v0.48' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'snowball_extra_payment' ) ) {
db . exec ( 'ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0' ) ;
}
console . log ( '[migration] users: snowball_extra_payment column added' ) ;
}
} ,
{
version : 'v0.50' ,
description : 'payments: balance_delta column for debt payoff tracking' ,
dependsOn : [ 'v0.49' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'balance_delta' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN balance_delta REAL' ) ;
}
console . log ( '[migration] payments: balance_delta column added' ) ;
}
2026-05-14 03:00:01 -05:00
} ,
{
version : 'v0.51' ,
description : 'bills: snowball_exempt column for hiding debt-like bills' ,
dependsOn : [ 'v0.50' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'snowball_exempt' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0' ) ;
}
console . log ( '[migration] bills: snowball_exempt column added' ) ;
}
2026-05-14 21:00:07 -05:00
} ,
{
version : 'v0.52' ,
description : 'users: last_seen_version for release-notes notifications' ,
dependsOn : [ 'v0.51' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'last_seen_version' ) ) {
db . exec ( 'ALTER TABLE users ADD COLUMN last_seen_version TEXT' ) ;
}
console . log ( '[migration] users: last_seen_version column added' ) ;
}
2026-05-15 01:36:56 -05:00
} ,
{
version : 'v0.53' ,
description : 'user_login_history: track last 3 logins per user' ,
dependsOn : [ 'v0.52' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS user _login _history (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
logged _in _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
ip _address TEXT ,
user _agent TEXT
)
` );
console . log ( '[migration] user_login_history table created' ) ;
}
2026-05-15 22:45:38 -05:00
} ,
{
version : 'v0.54' ,
description : 'user_settings: per-user display and billing preferences' ,
dependsOn : [ 'v0.53' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS user _settings (
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
key TEXT NOT NULL ,
value TEXT NOT NULL ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
PRIMARY KEY ( user _id , key )
)
` );
const userSettingKeys = [ 'currency' , 'date_format' , 'grace_period_days' , 'notify_days_before' ] ;
const users = db . prepare ( 'SELECT id FROM users' ) . all ( ) ;
const getCurrent = db . prepare ( 'SELECT value FROM settings WHERE key = ?' ) ;
const insert = db . prepare ( `
INSERT OR IGNORE INTO user _settings ( user _id , key , value )
VALUES ( ? , ? , ? )
` );
for ( const user of users ) {
for ( const key of userSettingKeys ) {
const row = getCurrent . get ( key ) ;
if ( row ) insert . run ( user . id , key , row . value ) ;
}
}
console . log ( '[migration] user_settings table created and seeded from current global defaults' ) ;
}
} ,
{
version : 'v0.55' ,
description : 'user_login_history: parsed device metadata and fingerprint' ,
dependsOn : [ 'v0.54' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS user _login _history (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
logged _in _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
ip _address TEXT ,
user _agent TEXT ,
browser TEXT ,
os TEXT ,
device _type TEXT ,
device _fingerprint TEXT
)
` );
const cols = db . prepare ( 'PRAGMA table_info(user_login_history)' ) . all ( ) . map ( c => c . name ) ;
const newCols = [
[ 'browser' , 'TEXT' ] ,
[ 'os' , 'TEXT' ] ,
[ 'device_type' , 'TEXT' ] ,
[ 'device_fingerprint' , 'TEXT' ] ,
] ;
for ( const [ col , def ] of newCols ) {
if ( ! cols . includes ( col ) ) {
db . exec ( ` ALTER TABLE user_login_history ADD COLUMN ${ col } ${ def } ` ) ;
}
}
console . log ( '[migration] user_login_history device metadata columns ensured' ) ;
}
2026-05-16 10:34:32 -05:00
} ,
{
version : 'v0.56' ,
description : 'bills/categories: soft-delete columns' ,
dependsOn : [ 'v0.55' ] ,
run : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const catCols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'deleted_at' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN deleted_at TEXT' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)' ) ;
}
if ( ! catCols . includes ( 'deleted_at' ) ) {
db . exec ( 'ALTER TABLE categories ADD COLUMN deleted_at TEXT' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)' ) ;
}
console . log ( '[migration] bills/categories deleted_at columns added' ) ;
}
2026-05-16 15:38:28 -05:00
} ,
{
version : 'v0.57' ,
description : 'autopay: suggestions and auto-mark paid' ,
dependsOn : [ 'v0.56' ] ,
run : function ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'auto_mark_paid' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0' ) ;
}
db . exec ( `
CREATE TABLE IF NOT EXISTS autopay _suggestion _dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
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 ) ,
dismissed _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , bill _id , year , month )
) ;
CREATE INDEX IF NOT EXISTS idx _autopay _suggestion _dismissals _user _month
ON autopay _suggestion _dismissals ( user _id , year , month ) ;
` );
console . log ( '[migration] autopay auto_mark_paid and suggestion dismissals ensured' ) ;
}
} ,
{
version : 'v0.58' ,
description : 'bills: saved bill templates' ,
dependsOn : [ 'v0.57' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS bill _templates (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
name TEXT NOT NULL ,
data TEXT NOT NULL ,
created _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , name )
) ;
CREATE INDEX IF NOT EXISTS idx _bill _templates _user _name
ON bill _templates ( user _id , name ) ;
` );
console . log ( '[migration] bill_templates table ensured' ) ;
}
2026-05-16 20:26:09 -05:00
} ,
{
version : 'v0.59' ,
description : 'payments: source metadata for future transaction matching' ,
dependsOn : [ 'v0.58' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'payment_source' ) ) {
db . exec ( "ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'" ) ;
}
if ( ! cols . includes ( 'transaction_id' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN transaction_id INTEGER' ) ;
}
console . log ( '[migration] payments: source metadata columns added' ) ;
}
} ,
{
version : 'v0.60' ,
description : 'transactions: shared transaction foundation tables' ,
dependsOn : [ 'v0.59' ] ,
run : function ( ) {
ensureTransactionFoundationSchema ( db ) ;
console . log ( '[migration] transaction foundation tables ensured' ) ;
}
2026-05-16 21:36:04 -05:00
} ,
{
version : 'v0.61' ,
description : 'payments: one active payment per linked transaction' ,
dependsOn : [ 'v0.60' ] ,
run : function ( ) {
db . exec ( `
CREATE UNIQUE INDEX IF NOT EXISTS idx _payments _transaction _active
ON payments ( transaction _id )
WHERE transaction _id IS NOT NULL AND deleted _at IS NULL
` );
console . log ( '[migration] payments: transaction active unique index ensured' ) ;
}
} ,
{
version : 'v0.62' ,
description : 'matches: rejected transaction match suggestions' ,
dependsOn : [ 'v0.61' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS match _suggestion _rejections (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
transaction _id INTEGER NOT NULL REFERENCES transactions ( id ) ON DELETE CASCADE ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
rejected _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , transaction _id , bill _id )
) ;
CREATE INDEX IF NOT EXISTS idx _match _suggestion _rejections _user
ON match _suggestion _rejections ( user _id , transaction _id , bill _id ) ;
` );
console . log ( '[migration] match suggestion rejections table ensured' ) ;
}
2026-05-28 22:54:07 -05:00
} ,
{
version : 'v0.63' ,
description : 'bills: subscription metadata fields' ,
dependsOn : [ 'v0.62' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'is_subscription' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0' ) ;
if ( ! cols . includes ( 'subscription_type' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN subscription_type TEXT' ) ;
if ( ! cols . includes ( 'reminder_days_before' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3' ) ;
if ( ! cols . includes ( 'subscription_source' ) ) db . exec ( "ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'" ) ;
if ( ! cols . includes ( 'subscription_detected_at' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)' ) ;
console . log ( '[migration] bills: subscription metadata columns added' ) ;
}
2026-05-29 01:06:20 -05:00
} ,
{
version : 'v0.64' ,
description : 'financial_accounts: monitored flag for bill matching' ,
dependsOn : [ 'v0.63' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(financial_accounts)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'monitored' ) ) {
db . exec ( 'ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1' ) ;
console . log ( '[migration] financial_accounts: monitored column added' ) ;
}
}
2026-05-29 01:51:42 -05:00
} ,
{
version : 'v0.65' ,
description : 'subscription_catalog: top-200 known subscription services' ,
dependsOn : [ 'v0.64' ] ,
run : function ( ) {
runSubscriptionCatalogMigration ( db ) ;
}
2026-05-29 02:51:30 -05:00
} ,
{
version : 'v0.66' ,
description : 'declined_subscription_hints: per-user dismissed recommendation store' ,
dependsOn : [ 'v0.65' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS declined _subscription _hints (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
decline _key TEXT NOT NULL ,
declined _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , decline _key )
) ;
CREATE INDEX IF NOT EXISTS idx _declined _hints _user
ON declined _subscription _hints ( user _id ) ;
` );
}
2026-05-29 03:38:48 -05:00
} ,
{
version : 'v0.67' ,
description : 'bill_merchant_rules: persistent merchant→bill auto-match rules' ,
dependsOn : [ 'v0.66' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS bill _merchant _rules (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
bill _id INTEGER NOT NULL REFERENCES bills ( id ) ON DELETE CASCADE ,
merchant TEXT NOT NULL ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , bill _id , merchant )
) ;
CREATE INDEX IF NOT EXISTS idx _bill _merchant _rules _user
ON bill _merchant _rules ( user _id ) ;
` );
}
2026-05-29 18:06:12 -05:00
} ,
{
version : 'v0.68' ,
description : 'advisory_non_bill_filters: 5k advisory patterns + bill-like override terms' ,
dependsOn : [ 'v0.67' ] ,
run : function ( ) {
runAdvisoryFiltersMigration ( db ) ;
}
2026-05-29 18:34:50 -05:00
} ,
{
version : 'v0.69' ,
description : 'subscription_catalog v2: 90 new services + category fixes' ,
dependsOn : [ 'v0.68' ] ,
run : function ( ) {
runSubscriptionCatalogV2Migration ( db ) ;
}
2026-05-30 13:19:09 -05:00
} ,
{
version : 'v0.70' ,
description : 'monthly_bill_state: add snoozed_until for overdue command center' ,
dependsOn : [ 'v0.69' ] ,
run : function ( ) {
2026-05-30 16:13:37 -05:00
const cols = db . prepare ( 'PRAGMA table_info(monthly_bill_state)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'snoozed_until' ) ) db . exec ( 'ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT' ) ;
2026-05-30 13:19:09 -05:00
}
2026-05-30 14:33:55 -05:00
} ,
{
version : 'v0.71' ,
description : 'bills: add drift_snoozed_until; users: add notify_amount_change' ,
dependsOn : [ 'v0.70' ] ,
run : function ( ) {
2026-05-30 16:13:37 -05:00
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const userCols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'drift_snoozed_until' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT' ) ;
if ( ! userCols . includes ( 'notify_amount_change' ) ) db . exec ( 'ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1' ) ;
}
} ,
{
version : 'v0.72' ,
description : 'bills: persistent tracker sort order' ,
dependsOn : [ 'v0.71' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'sort_order' ) ) db . exec ( 'ALTER TABLE bills ADD COLUMN sort_order INTEGER' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)' ) ;
2026-05-30 14:33:55 -05:00
}
2026-05-30 17:27:15 -05:00
} ,
{
version : 'v0.73' ,
description : 'add snowball_plans table for plan lifecycle + history' ,
dependsOn : [ 'v0.72' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS snowball _plans (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
name TEXT NOT NULL DEFAULT 'Snowball Plan' ,
method TEXT NOT NULL DEFAULT 'snowball' ,
status TEXT NOT NULL DEFAULT 'active' ,
started _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
paused _at TEXT ,
completed _at TEXT ,
extra _payment REAL NOT NULL DEFAULT 0 ,
plan _snapshot TEXT NOT NULL ,
notes TEXT ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
)
` );
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at)' ) ;
}
2026-05-30 17:57:34 -05:00
} ,
{
version : 'v0.74' ,
description : 'subscription_catalog: Claude.ai Anthropic matching' ,
dependsOn : [ 'v0.73' ] ,
run : function ( ) {
db . prepare ( `
UPDATE subscription _catalog
SET name = 'Claude.ai' ,
category = 'AI' ,
subscription _type = 'software' ,
website = 'https://claude.ai/upgrade' ,
domain = 'anthropic.com'
WHERE name IN ( 'Claude' , 'Claude.ai' )
OR domain IN ( 'claude.ai' , 'anthropic.com' )
` ).run();
db . prepare ( `
INSERT INTO subscription _catalog ( rank , name , category , subscription _type , website , domain )
SELECT 94 , 'Claude.ai' , 'AI' , 'software' , 'https://claude.ai/upgrade' , 'anthropic.com'
WHERE NOT EXISTS (
SELECT 1 FROM subscription _catalog
WHERE name = 'Claude.ai' OR domain IN ( 'claude.ai' , 'anthropic.com' )
)
` ).run();
}
2026-05-30 20:04:50 -05:00
} ,
{
version : 'v0.75' ,
description : 'categories: persistent sort order' ,
dependsOn : [ 'v0.74' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'sort_order' ) ) db . exec ( 'ALTER TABLE categories ADD COLUMN sort_order INTEGER' ) ;
db . exec ( 'CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)' ) ;
}
2026-05-30 21:20:51 -05:00
} ,
{
version : 'v0.76' ,
description : 'bills: canonical billing schedule cleanup' ,
dependsOn : [ 'v0.75' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'cycle_type' ) || ! cols . includes ( 'cycle_day' ) || ! cols . includes ( 'billing_cycle' ) ) return ;
db . exec ( `
UPDATE bills
SET cycle _type = CASE
WHEN LOWER ( COALESCE ( cycle _type , '' ) ) IN ( '' , 'monthly' )
AND LOWER ( COALESCE ( billing _cycle , '' ) ) = 'quarterly'
THEN 'quarterly'
WHEN LOWER ( COALESCE ( cycle _type , '' ) ) IN ( '' , 'monthly' )
AND LOWER ( COALESCE ( billing _cycle , '' ) ) IN ( 'annually' , 'annual' )
THEN 'annual'
WHEN LOWER ( COALESCE ( cycle _type , '' ) ) IN ( 'monthly' , 'weekly' , 'biweekly' , 'quarterly' , 'annual' )
THEN LOWER ( cycle _type )
ELSE 'monthly'
END ;
UPDATE bills
SET cycle _day = CASE
WHEN cycle _type IN ( 'weekly' , 'biweekly' )
AND LOWER ( COALESCE ( cycle _day , '' ) ) IN ( 'monday' , 'tuesday' , 'wednesday' , 'thursday' , 'friday' , 'saturday' , 'sunday' )
THEN LOWER ( cycle _day )
WHEN cycle _type IN ( 'weekly' , 'biweekly' )
THEN 'monday'
WHEN cycle _type IN ( 'quarterly' , 'annual' )
AND CAST ( cycle _day AS INTEGER ) BETWEEN 1 AND 12
THEN CAST ( CAST ( cycle _day AS INTEGER ) AS TEXT )
WHEN cycle _type IN ( 'quarterly' , 'annual' )
THEN '1'
WHEN cycle _type = 'monthly'
AND CAST ( cycle _day AS INTEGER ) BETWEEN 1 AND 31
THEN CAST ( CAST ( cycle _day AS INTEGER ) AS TEXT )
ELSE CAST ( CASE WHEN due _day BETWEEN 1 AND 31 THEN due _day ELSE 1 END AS TEXT )
END ;
UPDATE bills
SET billing _cycle = CASE
WHEN cycle _type = 'quarterly' THEN 'quarterly'
WHEN cycle _type = 'annual' THEN 'annually'
WHEN cycle _type IN ( 'weekly' , 'biweekly' ) THEN 'irregular'
ELSE 'monthly'
END ;
` );
}
2026-05-31 15:06:10 -05:00
} ,
{
version : 'v0.77' ,
description : 'encrypt SMTP password at rest' ,
dependsOn : [ 'v0.76' ] ,
run : function ( ) {
try {
const { decryptSecret , encryptSecret } = require ( '../services/encryptionService' ) ;
const row = db . prepare ( "SELECT value FROM settings WHERE key = 'notify_smtp_password'" ) . get ( ) ;
if ( row ? . value ) {
try {
decryptSecret ( row . value ) ; // already encrypted — skip
} catch {
// plaintext — encrypt it
db . prepare ( "UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'" )
. run ( encryptSecret ( row . value ) ) ;
}
}
} catch ( err ) {
console . warn ( '[v0.77] SMTP password encryption migration failed:' , err . message ) ;
}
}
2026-05-31 15:52:50 -05:00
} ,
{
version : 'v0.78' ,
description : 're-encrypt secrets from SHA-256 to HKDF key derivation' ,
dependsOn : [ 'v0.77' ] ,
run : function ( ) {
try {
const { decryptSecret , encryptSecret } = require ( '../services/encryptionService' ) ;
// Re-encrypt SimpleFIN tokens in data_sources
const sources = db . prepare (
"SELECT id, encrypted_secret FROM data_sources WHERE encrypted_secret IS NOT NULL AND encrypted_secret NOT LIKE 'v2:%'"
) . all ( ) ;
const updateSource = db . prepare ( 'UPDATE data_sources SET encrypted_secret = ? WHERE id = ?' ) ;
for ( const row of sources ) {
try {
updateSource . run ( encryptSecret ( decryptSecret ( row . encrypted _secret ) ) , row . id ) ;
} catch ( err ) {
console . warn ( ` [v0.78] Could not re-encrypt data_source id= ${ row . id } : ` , err . message ) ;
}
}
// Re-encrypt SMTP password
const smtp = db . prepare ( "SELECT value FROM settings WHERE key = 'notify_smtp_password'" ) . get ( ) ;
if ( smtp ? . value && ! smtp . value . startsWith ( 'v2:' ) ) {
try {
db . prepare ( "UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'" )
. run ( encryptSecret ( decryptSecret ( smtp . value ) ) ) ;
} catch ( err ) {
console . warn ( '[v0.78] Could not re-encrypt SMTP password:' , err . message ) ;
}
}
} catch ( err ) {
console . warn ( '[v0.78] HKDF re-encryption migration failed:' , err . message ) ;
}
}
2026-06-03 20:28:37 -05:00
} ,
{
version : 'v0.79' ,
description : 'encrypt OIDC client secret at rest' ,
dependsOn : [ 'v0.78' ] ,
run : function ( ) {
try {
const { decryptSecret , encryptSecret } = require ( '../services/encryptionService' ) ;
const row = db . prepare ( "SELECT value FROM settings WHERE key = 'oidc_client_secret'" ) . get ( ) ;
if ( row ? . value ) {
try {
decryptSecret ( row . value ) ; // already encrypted — skip
} catch {
// plaintext — encrypt it
db . prepare ( "UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'oidc_client_secret'" )
. run ( encryptSecret ( row . value ) ) ;
}
}
} catch ( err ) {
console . warn ( '[v0.79] OIDC client secret encryption migration failed:' , err . message ) ;
}
}
2026-06-03 21:43:54 -05:00
} ,
{
version : 'v0.80' ,
description : 'users: push notification columns (ntfy / Gotify / Discord / Telegram)' ,
dependsOn : [ 'v0.79' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
const add = ( col , def ) => {
if ( ! cols . includes ( col ) ) db . exec ( ` ALTER TABLE users ADD COLUMN ${ col } ${ def } ` ) ;
} ;
add ( 'notify_push_enabled' , 'INTEGER NOT NULL DEFAULT 0' ) ;
add ( 'push_channel' , 'TEXT' ) ;
add ( 'push_url' , 'TEXT' ) ;
add ( 'push_token' , 'TEXT' ) ;
add ( 'push_chat_id' , 'TEXT' ) ;
console . log ( '[v0.80] push notification columns added' ) ;
}
2026-06-03 22:25:30 -05:00
} ,
{
version : 'v0.81' ,
description : 'bill_merchant_rules: composite index on (user_id, bill_id) for faster EXISTS lookups' ,
dependsOn : [ 'v0.80' ] ,
run : function ( ) {
db . exec ( `
CREATE INDEX IF NOT EXISTS idx _bill _merchant _rules _user _bill
ON bill _merchant _rules ( user _id , bill _id )
` );
console . log ( '[v0.81] bill_merchant_rules composite index added' ) ;
}
2026-06-03 23:29:30 -05:00
} ,
{
version : 'v0.82' ,
description : 'payments: normalise auto_match source to provider_sync' ,
dependsOn : [ 'v0.81' ] ,
run : function ( ) {
const result = db . prepare (
"UPDATE payments SET payment_source = 'provider_sync' WHERE payment_source = 'auto_match'"
) . run ( ) ;
console . log ( ` [v0.82] Normalised ${ result . changes } auto_match payment(s) to provider_sync ` ) ;
}
2026-06-04 02:36:36 -05:00
} ,
{
version : 'v0.83' ,
description : 'bill_merchant_rules: auto_attribute_late flag for bills that always post after month end' ,
dependsOn : [ 'v0.82' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(bill_merchant_rules)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'auto_attribute_late' ) ) {
db . exec ( 'ALTER TABLE bill_merchant_rules ADD COLUMN auto_attribute_late INTEGER NOT NULL DEFAULT 0' ) ;
console . log ( '[v0.83] bill_merchant_rules.auto_attribute_late added' ) ;
}
}
2026-06-04 03:38:32 -05:00
} ,
{
version : 'v0.84' ,
description : 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records' ,
dependsOn : [ 'v0.83' ] ,
run : function ( ) {
const { encryptSecret , decryptSecret } = require ( '../services/encryptionService' ) ;
const cols = db . prepare ( 'PRAGMA table_info(user_login_history)' ) . all ( ) . map ( c => c . name ) ;
// Add location columns
const newCols = [ 'location_city' , 'location_country' , 'location_region' , 'location_isp' ] ;
for ( const col of newCols ) {
if ( ! cols . includes ( col ) ) db . exec ( ` ALTER TABLE user_login_history ADD COLUMN ${ col } TEXT ` ) ;
}
// Encrypt existing plaintext ip_address and user_agent rows
const rows = db . prepare ( 'SELECT id, ip_address, user_agent FROM user_login_history' ) . all ( ) ;
const updIp = db . prepare ( "UPDATE user_login_history SET ip_address=? WHERE id=?" ) ;
const updUa = db . prepare ( "UPDATE user_login_history SET user_agent=? WHERE id=?" ) ;
for ( const row of rows ) {
if ( row . ip _address && ! row . ip _address . startsWith ( 'v2:' ) ) {
try { updIp . run ( encryptSecret ( row . ip _address ) , row . id ) ; } catch { }
}
if ( row . user _agent && ! row . user _agent . startsWith ( 'v2:' ) ) {
try { updUa . run ( encryptSecret ( row . user _agent ) , row . id ) ; } catch { }
}
}
console . log ( ` [v0.84] login history: location columns added, ${ rows . length } rows encrypted ` ) ;
}
} ,
{
version : 'v0.85' ,
description : 'user_login_history: failed attempt tracking + session fingerprint for current-session detection' ,
dependsOn : [ 'v0.84' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(user_login_history)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'success' ) ) {
db . exec ( 'ALTER TABLE user_login_history ADD COLUMN success INTEGER NOT NULL DEFAULT 1' ) ;
}
if ( ! cols . includes ( 'session_fingerprint' ) ) {
db . exec ( 'ALTER TABLE user_login_history ADD COLUMN session_fingerprint TEXT' ) ;
}
console . log ( '[v0.85] user_login_history: success + session_fingerprint columns added' ) ;
}
2026-06-04 04:10:14 -05:00
} ,
{
version : 'v0.86' ,
description : 'users: TOTP/authenticator 2FA columns + totp_challenges table' ,
dependsOn : [ 'v0.85' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'totp_enabled' ) )
db . exec ( 'ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0' ) ;
if ( ! cols . includes ( 'totp_secret' ) )
db . exec ( 'ALTER TABLE users ADD COLUMN totp_secret TEXT' ) ;
if ( ! cols . includes ( 'totp_recovery_codes' ) )
db . exec ( 'ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT' ) ;
db . exec ( `
CREATE TABLE IF NOT EXISTS totp _challenges (
id TEXT PRIMARY KEY ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
expires _at TEXT NOT NULL ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
)
` );
console . log ( '[v0.86] users: TOTP columns + totp_challenges table' ) ;
}
2026-06-04 04:31:25 -05:00
} ,
{
version : 'v0.87' ,
description : 'spending: category assignment on transactions + rules + budgets + default categories' ,
dependsOn : [ 'v0.86' ] ,
run : function ( ) {
// spending_category_id on transactions
const txCols = db . prepare ( 'PRAGMA table_info(transactions)' ) . all ( ) . map ( c => c . name ) ;
if ( ! txCols . includes ( 'spending_category_id' ) )
db . exec ( 'ALTER TABLE transactions ADD COLUMN spending_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL' ) ;
// spending category rules (merchant → category)
db . exec ( `
CREATE TABLE IF NOT EXISTS spending _category _rules (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
category _id INTEGER NOT NULL REFERENCES categories ( id ) ON DELETE CASCADE ,
merchant TEXT NOT NULL ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , merchant )
)
` );
// monthly spending budgets
db . exec ( `
CREATE TABLE IF NOT EXISTS spending _budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
category _id INTEGER NOT NULL REFERENCES categories ( id ) ON DELETE CASCADE ,
year INTEGER NOT NULL ,
month INTEGER NOT NULL ,
amount REAL NOT NULL DEFAULT 0 ,
updated _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , category _id , year , month )
)
` );
// Seed default spending categories for each user that has none yet
const DEFAULTS = [ 'Groceries' , 'Dining' , 'Fuel & Transport' , 'Shopping' , 'Entertainment' , 'Health' , 'Travel' , 'Other' ] ;
const users = db . prepare ( "SELECT id FROM users WHERE role='user' AND active=1" ) . all ( ) ;
const insert = db . prepare ( "INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)" ) ;
for ( const user of users ) {
const existing = db . prepare ( "SELECT COUNT(*) AS n FROM categories WHERE user_id=? AND deleted_at IS NULL" ) . get ( user . id ) ;
if ( ( existing ? . n ? ? 0 ) === 0 ) {
DEFAULTS . forEach ( ( name , i ) => insert . run ( user . id , name , 100 + i ) ) ;
}
}
console . log ( '[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets' ) ;
}
2026-06-04 20:01:51 -05:00
} ,
{
version : 'v0.88' ,
description : 'categories: spending_enabled flag to separate bill vs spending categories' ,
dependsOn : [ 'v0.87' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'spending_enabled' ) )
db . exec ( 'ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0' ) ;
// Mark the v0.87-seeded defaults as spending-enabled
const SPENDING _DEFAULTS = [ 'Groceries' , 'Dining' , 'Fuel & Transport' , 'Shopping' , 'Entertainment' , 'Health' , 'Travel' , 'Other' ] ;
const placeholder = SPENDING _DEFAULTS . map ( ( ) => '?' ) . join ( ',' ) ;
db . exec ( ` UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN ( ${ SPENDING _DEFAULTS . map ( n => ` ' ${ n . replace ( "'" , "''" ) } ' ` ) . join ( ',' ) } ) ` ) ;
// Mark any category already linked to a spending rule as spending-enabled
try {
db . exec ( `
UPDATE categories SET spending _enabled = 1
WHERE id IN ( SELECT DISTINCT category _id FROM spending _category _rules )
` );
} catch { /* spending_category_rules may not exist on legacy paths */ }
console . log ( '[v0.88] categories.spending_enabled added, seeded defaults marked' ) ;
}
} ,
{
version : 'v0.89' ,
description : 'categories: seed spending defaults for users who had existing categories before v0.87' ,
dependsOn : [ 'v0.88' ] ,
run : function ( ) {
const SPENDING _DEFAULTS = [
'Groceries' , 'Dining' , 'Fuel & Transport' , 'Shopping' ,
'Entertainment' , 'Health' , 'Travel' , 'Other'
] ;
const users = db . prepare ( "SELECT id FROM users WHERE role='user' AND active=1" ) . all ( ) ;
const insert = db . prepare ( `
INSERT OR IGNORE INTO categories ( user _id , name , sort _order , is _seeded , spending _enabled )
VALUES ( ? , ? , ? , 1 , 1 )
` );
let seeded = 0 ;
for ( const user of users ) {
const hasSpending = db . prepare ( 'SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1' ) . get ( user . id ) ;
if ( ! hasSpending ) {
SPENDING _DEFAULTS . forEach ( ( name , i ) => { insert . run ( user . id , name , 200 + i ) ; seeded ++ ; } ) ;
}
}
console . log ( ` [v0.89] spending defaults seeded for users missing them ( ${ seeded } categories inserted) ` ) ;
}
2026-06-04 20:45:11 -05:00
} ,
{
version : 'v0.90' ,
description : 're-normalize merchant rules after & fix; ensure rejection expiry column' ,
dependsOn : [ 'v0.89' ] ,
run : function ( ) {
const { normalizeMerchant } = require ( '../services/subscriptionService' ) ;
// Re-normalize bill_merchant_rules stored under old normalization ("at t" → "att")
let billFixed = 0 ;
2026-06-04 20:52:50 -05:00
try {
const rules = db . prepare ( 'SELECT id, merchant FROM bill_merchant_rules' ) . all ( ) ;
const updBill = db . prepare ( 'UPDATE bill_merchant_rules SET merchant=? WHERE id=?' ) ;
for ( const r of rules ) {
try {
const fixed = normalizeMerchant ( r . merchant ) ;
if ( fixed && fixed !== r . merchant ) { updBill . run ( fixed , r . id ) ; billFixed ++ ; }
} catch { /* skip invalid entries */ }
}
} catch ( err ) {
console . warn ( '[v0.90] bill_merchant_rules re-normalize skipped:' , err . message ) ;
2026-06-04 20:45:11 -05:00
}
// Re-normalize spending_category_rules
try {
const srules = db . prepare ( 'SELECT id, merchant FROM spending_category_rules' ) . all ( ) ;
const updSpend = db . prepare ( 'UPDATE spending_category_rules SET merchant=? WHERE id=?' ) ;
let spendFixed = 0 ;
for ( const r of srules ) {
const fixed = normalizeMerchant ( r . merchant ) ;
if ( fixed !== r . merchant ) { updSpend . run ( fixed , r . id ) ; spendFixed ++ ; }
}
if ( spendFixed ) console . log ( ` [v0.90] spending_category_rules: ${ spendFixed } re-normalized ` ) ;
} catch { /* spending_category_rules may not exist on legacy DBs */ }
// Ensure match_suggestion_rejections has created_at for expiry queries
const rejCols = db . prepare ( 'PRAGMA table_info(match_suggestion_rejections)' ) . all ( ) . map ( c => c . name ) ;
if ( ! rejCols . includes ( 'created_at' ) ) {
// Static default — existing rejections get a past date so they expire immediately on next cleanup
db . exec ( "ALTER TABLE match_suggestion_rejections ADD COLUMN created_at TEXT NOT NULL DEFAULT '2000-01-01'" ) ;
}
console . log ( ` [v0.90] merchant rules re-normalized ( ${ billFixed } bill rules updated), rejection expiry column ensured ` ) ;
}
2026-06-04 21:00:59 -05:00
} ,
{
version : 'v0.91' ,
description : 'performance: composite indexes on user_id+deleted_at for categories, bills, payments' ,
dependsOn : [ 'v0.90' ] ,
run : function ( ) {
db . exec ( `
CREATE INDEX IF NOT EXISTS idx _categories _user _deleted ON categories ( user _id , deleted _at ) ;
CREATE INDEX IF NOT EXISTS idx _bills _user _deleted ON bills ( user _id , deleted _at ) ;
CREATE INDEX IF NOT EXISTS idx _bills _user _active ON bills ( user _id , active , deleted _at ) ;
CREATE INDEX IF NOT EXISTS idx _payments _bill _deleted ON payments ( bill _id , deleted _at ) ;
` );
console . log ( '[v0.91] composite indexes created on categories, bills, payments' ) ;
}
2026-06-05 22:05:23 -05:00
} ,
{
version : 'v0.92' ,
description : 'auth: WebAuthn/FIDO2 security key support — webauthn_credentials + webauthn_challenges tables' ,
dependsOn : [ 'v0.91' ] ,
run : function ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'webauthn_enabled' ) )
db . exec ( 'ALTER TABLE users ADD COLUMN webauthn_enabled INTEGER NOT NULL DEFAULT 0' ) ;
if ( ! cols . includes ( 'webauthn_user_id' ) )
db . exec ( 'ALTER TABLE users ADD COLUMN webauthn_user_id TEXT' ) ;
db . exec ( `
CREATE TABLE IF NOT EXISTS webauthn _credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
credential _id TEXT NOT NULL UNIQUE ,
public _key TEXT NOT NULL ,
sign _count INTEGER NOT NULL DEFAULT 0 ,
transports TEXT ,
backup _eligible INTEGER NOT NULL DEFAULT 0 ,
backup _state INTEGER NOT NULL DEFAULT 0 ,
credential _name TEXT NOT NULL DEFAULT 'Security Key' ,
aaguid TEXT ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _webauthn _creds _user ON webauthn _credentials ( user _id ) ;
CREATE TABLE IF NOT EXISTS webauthn _challenges (
id TEXT PRIMARY KEY ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
challenge _type TEXT NOT NULL CHECK ( challenge _type IN ( 'registration' , 'authentication' , 'login' ) ) ,
challenge TEXT NOT NULL DEFAULT '' ,
expires _at TEXT NOT NULL ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _webauthn _challenges _user ON webauthn _challenges ( user _id ) ;
CREATE INDEX IF NOT EXISTS idx _webauthn _challenges _expires ON webauthn _challenges ( expires _at ) ;
` );
console . log ( '[v0.92] WebAuthn tables + users columns added' ) ;
}
2026-06-06 16:34:20 -05:00
} ,
{
version : 'v0.93' ,
description : 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key + dedupe index' ,
dependsOn : [ 'v0.92' ] ,
run : function ( ) {
// 1. Track the calendar month when interest was last applied to a debt bill
// so computeBalanceDelta can skip interest if it was already charged this month.
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'interest_accrued_month' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN interest_accrued_month TEXT' ) ;
console . log ( '[v0.93] bills.interest_accrued_month column added' ) ;
}
// 2. Track the interest component of each payment separately so delete/restore
// can handle it without double-charging interest.
const paymentCols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! paymentCols . includes ( 'interest_delta' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN interest_delta REAL' ) ;
console . log ( '[v0.93] payments.interest_delta column added' ) ;
}
// 3. Strip the data_source_id from existing provider_transaction_id keys so
// they survive disconnect/reconnect. Old: "simplefin:{dsId}:{acctId}:{txId}"
// New: "simplefin:{acctId}:{txId}".
// Only rows where the segment after "simplefin:" is a numeric id are migrated.
db . exec ( `
UPDATE transactions
SET provider _transaction _id =
'simplefin:' || SUBSTR (
provider _transaction _id ,
INSTR ( SUBSTR ( provider _transaction _id , 11 ) , ':' ) + 11
)
WHERE provider _transaction _id LIKE 'simplefin:%'
AND CAST (
SUBSTR ( provider _transaction _id , 11 ,
INSTR ( SUBSTR ( provider _transaction _id , 11 ) , ':' ) - 1 )
AS INTEGER ) > 0
` );
console . log ( '[v0.93] transactions: stripped data_source_id from provider_transaction_id' ) ;
// 4. Dedup: after the key change, users who disconnected and reconnected now
// have duplicate (user_id, provider_transaction_id) pairs. Keep the best row
// (prefer linked rows; break ties by most-recent created_at).
db . exec ( `
DELETE FROM transactions
WHERE id IN (
SELECT id FROM (
SELECT id ,
ROW _NUMBER ( ) OVER (
PARTITION BY user _id , provider _transaction _id
ORDER BY ( data _source _id IS NULL ) ASC , created _at DESC
) AS rn
FROM transactions
WHERE provider _transaction _id IS NOT NULL
)
WHERE rn > 1
)
` );
console . log ( '[v0.93] transactions: removed duplicate provider keys from disconnect/reconnect' ) ;
// 5. Replace the old dedupe index (data_source_id, provider_transaction_id)
// with a user-scoped one (user_id, provider_transaction_id) so reconnect
// with a new data_source_id still deduplicates correctly.
db . exec ( `
DROP INDEX IF EXISTS idx _transactions _provider _dedupe ;
CREATE UNIQUE INDEX idx _transactions _provider _dedupe
ON transactions ( user _id , provider _transaction _id )
WHERE provider _transaction _id IS NOT NULL ;
` );
console . log ( '[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)' ) ;
}
2026-06-06 17:00:22 -05:00
} ,
{
version : 'v0.94' ,
description : 'security: session token hashing + geolocation opt-in setting' ,
2026-06-06 17:17:10 -05:00
run ( ) {
2026-06-06 17:00:22 -05:00
// Seed the geolocation setting (default off) for existing installations
db . prepare ( "INSERT OR IGNORE INTO settings (key, value) VALUES ('geolocation_enabled', 'false')" ) . run ( ) ;
console . log ( '[v0.94] geolocation_enabled setting seeded' ) ;
// All existing plaintext session IDs are invalidated so everyone re-authenticates.
// Going forward, sessions.id stores SHA-256(token); the raw token stays in the cookie.
const count = db . prepare ( 'SELECT COUNT(*) as n FROM sessions' ) . get ( ) . n ;
db . exec ( 'DELETE FROM sessions' ) ;
console . log ( ` [v0.94] sessions: cleared ${ count } existing plaintext sessions (re-login required) ` ) ;
}
} ,
2026-06-06 20:02:13 -05:00
{
version : 'v0.95' ,
description : 'subscription_catalog: bank descriptors + pricing from 2026 researched dataset' ,
run ( ) {
// 1. Add new columns to subscription_catalog
const cols = db . prepare ( 'PRAGMA table_info(subscription_catalog)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'subcategory' ) ) db . exec ( 'ALTER TABLE subscription_catalog ADD COLUMN subcategory TEXT' ) ;
if ( ! cols . includes ( 'starting_monthly_usd' ) ) db . exec ( 'ALTER TABLE subscription_catalog ADD COLUMN starting_monthly_usd REAL' ) ;
if ( ! cols . includes ( 'starting_annual_usd' ) ) db . exec ( 'ALTER TABLE subscription_catalog ADD COLUMN starting_annual_usd REAL' ) ;
if ( ! cols . includes ( 'price_notes' ) ) db . exec ( 'ALTER TABLE subscription_catalog ADD COLUMN price_notes TEXT' ) ;
// 2. Create descriptors table (bank statement strings + slang/nicknames per service)
db . exec ( `
CREATE TABLE IF NOT EXISTS subscription _catalog _descriptors (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
catalog _id INTEGER NOT NULL REFERENCES subscription _catalog ( id ) ON DELETE CASCADE ,
descriptor TEXT NOT NULL ,
descriptor _type TEXT NOT NULL CHECK ( descriptor _type IN ( 'bank' , 'slang' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _scd _catalog _id ON subscription _catalog _descriptors ( catalog _id ) ;
` );
// 3. Load researched JSON — path is relative to this file's directory (db/)
const path = require ( 'path' ) ;
// eslint-disable-next-line global-require
const { subscriptions } = require ( path . join ( _ _dirname , '../docs/top_200_us_subscriptions_researched_2026-06-06.json' ) ) ;
// Map rich category labels from the JSON to our internal subscription_type values
const CATEGORY _TYPE = {
'Video Streaming' : 'streaming' , 'Sports Streaming' : 'streaming' ,
'Live TV Streaming' : 'streaming' , 'Sports Media' : 'streaming' ,
'Music & Audio' : 'music' , 'Podcasts' : 'music' ,
'Gaming' : 'gaming' ,
'News & Magazines' : 'news' ,
'Fitness & Wellness' : 'fitness' , 'Meditation & Wellness' : 'fitness' ,
'Sleep & Wellness' : 'fitness' ,
'Software & Productivity' : 'software' , 'Software & Design' : 'software' ,
'Developer Tools' : 'software' , 'Finance Software' : 'software' ,
'AI' : 'software' , 'Writing & AI' : 'software' ,
'Cloud & Storage' : 'cloud' ,
'Security' : 'security' ,
'Food & Meal Kits' : 'food' , 'Prepared Meals' : 'food' ,
'Food Delivery' : 'food' , 'Food & Rides' : 'food' ,
'Coffee & Tea' : 'food' , 'Snacks' : 'food' ,
'Grocery & Delivery' : 'shopping' , 'Shopping & Delivery' : 'shopping' ,
'Retail Memberships' : 'shopping' , 'Warehouse Clubs' : 'shopping' ,
'Pet Retail' : 'shopping' ,
'Education' : 'education' , 'Audiobooks' : 'education' ,
'Audiobooks & Ebooks' : 'education' , 'Ebooks & Audiobooks' : 'education' ,
'Ebooks' : 'education' , 'Documents & Ebooks' : 'education' ,
'Books & Learning' : 'education' , 'Books & Subscription Boxes' : 'education' ,
'Creator & Social' : 'other' , 'Creator Media' : 'other' ,
'Dating' : 'other' , 'Career & Social' : 'other' ,
} ;
const getByName = db . prepare ( 'SELECT id FROM subscription_catalog WHERE name = ? LIMIT 1' ) ;
const updateCatalog = db . prepare ( `
UPDATE subscription _catalog
SET rank = ? , category = ? , subcategory = ? , subscription _type = ? ,
website = ? , domain = ? , starting _monthly _usd = ? , starting _annual _usd = ? , price _notes = ?
WHERE id = ?
` );
const insertCatalog = db . prepare ( `
INSERT INTO subscription _catalog
( rank , name , category , subcategory , subscription _type , website , domain , starting _monthly _usd , starting _annual _usd , price _notes )
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? )
` );
const clearDescs = db . prepare ( 'DELETE FROM subscription_catalog_descriptors WHERE catalog_id = ?' ) ;
const insertDesc = db . prepare ( 'INSERT INTO subscription_catalog_descriptors (catalog_id, descriptor, descriptor_type) VALUES (?, ?, ?)' ) ;
let nUpdated = 0 , nInserted = 0 , nDescs = 0 ;
db . transaction ( ( ) => {
for ( const sub of subscriptions ) {
const subType = CATEGORY _TYPE [ sub . category ] || 'other' ;
let domain = null ;
try { domain = new URL ( sub . website || '' ) . hostname . replace ( /^www\./ , '' ) ; } catch { }
const existing = getByName . get ( sub . service ) ;
let catalogId ;
if ( existing ) {
updateCatalog . run (
sub . rank , sub . category , sub . subcategory || null , subType ,
sub . website || null , domain ,
sub . starting _monthly _usd ? ? null , sub . starting _annual _usd ? ? null ,
sub . price _notes || null ,
existing . id ,
) ;
catalogId = existing . id ;
nUpdated ++ ;
} else {
const r = insertCatalog . run (
sub . rank , sub . service , sub . category , sub . subcategory || null , subType ,
sub . website || null , domain ,
sub . starting _monthly _usd ? ? null , sub . starting _annual _usd ? ? null ,
sub . price _notes || null ,
) ;
catalogId = r . lastInsertRowid ;
nInserted ++ ;
}
// Replace all descriptors for this entry
clearDescs . run ( catalogId ) ;
for ( const d of ( sub . bank _statement _name _variables || [ ] ) ) {
if ( String ( d ) . trim ( ) . length >= 3 ) { insertDesc . run ( catalogId , String ( d ) . trim ( ) , 'bank' ) ; nDescs ++ ; }
}
for ( const d of ( sub . known _names _and _slang || [ ] ) ) {
if ( String ( d ) . trim ( ) . length >= 2 ) { insertDesc . run ( catalogId , String ( d ) . trim ( ) , 'slang' ) ; nDescs ++ ; }
}
}
} ) ( ) ;
console . log ( ` [v0.95] catalog: ${ nUpdated } updated, ${ nInserted } inserted, ${ nDescs } descriptors added ` ) ;
}
} ,
{
version : 'v0.96' ,
description : 'bills: catalog_id FK; user_catalog_descriptors for custom bank descriptors' ,
run ( ) {
// 1. Add catalog_id to bills
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'catalog_id' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN catalog_id INTEGER REFERENCES subscription_catalog(id)' ) ;
}
// 2. Create per-user custom descriptor table
db . exec ( `
CREATE TABLE IF NOT EXISTS user _catalog _descriptors (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
catalog _id INTEGER NOT NULL REFERENCES subscription _catalog ( id ) ON DELETE CASCADE ,
descriptor TEXT NOT NULL ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _ucd _user _catalog ON user _catalog _descriptors ( user _id , catalog _id ) ;
` );
// 3. Backfill catalog_id for existing subscription bills using name normalization
function normSimple ( s ) {
return String ( s || '' ) . toLowerCase ( ) . replace ( /[^a-z0-9]/g , '' ) ;
}
const catalogEntries = db . prepare ( 'SELECT id, name FROM subscription_catalog' ) . all ( ) ;
const subBills = db . prepare (
"SELECT id, name FROM bills WHERE is_subscription = 1 AND deleted_at IS NULL AND catalog_id IS NULL"
) . all ( ) ;
const updateBillCatalog = db . prepare ( 'UPDATE bills SET catalog_id = ? WHERE id = ?' ) ;
let backfilled = 0 ;
db . transaction ( ( ) => {
for ( const bill of subBills ) {
const billNorm = normSimple ( bill . name ) ;
if ( billNorm . length < 3 ) continue ;
let best = null ;
let bestScore = 0 ;
for ( const cat of catalogEntries ) {
const catNorm = normSimple ( cat . name ) ;
if ( catNorm . length < 3 ) continue ;
let score = 0 ;
if ( billNorm === catNorm ) score = 2000 + catNorm . length ;
else if ( billNorm . includes ( catNorm ) || catNorm . includes ( billNorm ) )
score = 1000 + Math . min ( billNorm . length , catNorm . length ) ;
if ( score > bestScore ) { best = cat ; bestScore = score ; }
}
if ( best ) { updateBillCatalog . run ( best . id , bill . id ) ; backfilled ++ ; }
}
} ) ( ) ;
console . log ( ` [v0.96] catalog_id added to bills; ${ backfilled } / ${ subBills . length } subscriptions backfilled ` ) ;
}
} ,
2026-06-06 21:15:08 -05:00
{
version : 'v0.97' ,
description : 'subscription recommendation feedback: per-user learning signals' ,
run ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS subscription _recommendation _feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
catalog _id INTEGER REFERENCES subscription _catalog ( id ) ON DELETE SET NULL ,
bill _id INTEGER REFERENCES bills ( id ) ON DELETE SET NULL ,
merchant TEXT ,
action TEXT NOT NULL ,
confidence INTEGER ,
descriptor TEXT ,
metadata _json TEXT ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
CREATE INDEX IF NOT EXISTS idx _srf _user _catalog
ON subscription _recommendation _feedback ( user _id , catalog _id ) ;
CREATE INDEX IF NOT EXISTS idx _srf _user _merchant
ON subscription _recommendation _feedback ( user _id , merchant ) ;
CREATE INDEX IF NOT EXISTS idx _srf _user _action
ON subscription _recommendation _feedback ( user _id , action ) ;
` );
console . log ( '[v0.97] subscription recommendation feedback table ensured' ) ;
}
} ,
2026-06-07 01:05:48 -05:00
{
version : 'v0.98' ,
description : 'payments: bank override metadata for provisional manual payments' ,
run ( ) {
const cols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'accounting_excluded' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN accounting_excluded INTEGER NOT NULL DEFAULT 0' ) ;
}
if ( ! cols . includes ( 'exclusion_reason' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN exclusion_reason TEXT' ) ;
}
if ( ! cols . includes ( 'excluded_at' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN excluded_at TEXT' ) ;
}
if ( ! cols . includes ( 'overridden_by_payment_id' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN overridden_by_payment_id INTEGER' ) ;
}
db . exec ( `
CREATE INDEX IF NOT EXISTS idx _payments _accounting _active
ON payments ( bill _id , paid _date , deleted _at , accounting _excluded ) ;
CREATE INDEX IF NOT EXISTS idx _payments _overridden _by
ON payments ( overridden _by _payment _id )
WHERE overridden _by _payment _id IS NOT NULL ;
` );
console . log ( '[v0.98] payment accounting override columns ensured' ) ;
}
} ,
2026-06-07 14:49:39 -05:00
{
version : 'v0.99' ,
description : 'bills: autopay trust indicators + lifecycle fields; payments: autopay failure flag' ,
check ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
return billCols . includes ( 'autopay_verified_at' ) && billCols . includes ( 'inactive_reason' ) ;
} ,
run ( ) {
const billCols = db . prepare ( 'PRAGMA table_info(bills)' ) . all ( ) . map ( c => c . name ) ;
const paymentCols = db . prepare ( 'PRAGMA table_info(payments)' ) . all ( ) . map ( c => c . name ) ;
if ( ! billCols . includes ( 'autopay_verified_at' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN autopay_verified_at TEXT' ) ;
}
if ( ! billCols . includes ( 'inactive_reason' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN inactive_reason TEXT' ) ;
}
if ( ! billCols . includes ( 'inactivated_at' ) ) {
db . exec ( 'ALTER TABLE bills ADD COLUMN inactivated_at TEXT' ) ;
}
if ( ! paymentCols . includes ( 'autopay_failure' ) ) {
db . exec ( 'ALTER TABLE payments ADD COLUMN autopay_failure INTEGER NOT NULL DEFAULT 0' ) ;
}
console . log ( '[v0.99] autopay trust indicators + lifecycle fields added' ) ;
}
} ,
2026-06-07 15:53:46 -05:00
{
version : 'v1.00' ,
description : 'calendar feed subscription tokens' ,
run ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS calendar _tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
token TEXT NOT NULL UNIQUE ,
label TEXT ,
active INTEGER NOT NULL DEFAULT 1 ,
last _used _at TEXT ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
revoked _at TEXT
) ;
CREATE INDEX IF NOT EXISTS idx _calendar _tokens _token ON calendar _tokens ( token ) WHERE active = 1 ;
CREATE INDEX IF NOT EXISTS idx _calendar _tokens _user _active ON calendar _tokens ( user _id , active ) ;
` );
console . log ( '[v1.00] calendar feed token table ensured' ) ;
}
} ,
2026-06-07 20:07:27 -05:00
{
version : 'v1.01' ,
description : 'transactions: pending flag for SimpleFIN pending transactions' ,
run ( ) {
const cols = db . prepare ( 'PRAGMA table_info(transactions)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'pending' ) ) {
db . exec ( 'ALTER TABLE transactions ADD COLUMN pending INTEGER NOT NULL DEFAULT 0' ) ;
}
// Partial index speeds the per-account orphan prune that clears pending rows
// which never posted (e.g. a pending charge that re-posted under a new id).
db . exec ( ` CREATE INDEX IF NOT EXISTS idx_transactions_pending
ON transactions ( account _id ) WHERE pending = 1 ` );
console . log ( '[v1.01] transactions.pending flag + partial index added' ) ;
}
} ,
2026-06-07 21:18:02 -05:00
{
version : 'v1.02' ,
description : 'users: per-user geolocation opt-in (was global admin setting)' ,
run ( ) {
const cols = db . prepare ( 'PRAGMA table_info(users)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'geolocation_enabled' ) ) {
db . exec ( 'ALTER TABLE users ADD COLUMN geolocation_enabled INTEGER NOT NULL DEFAULT 0' ) ;
}
console . log ( '[v1.02] users.geolocation_enabled added' ) ;
}
} ,
2026-06-11 20:12:31 -05:00
{
version : 'v1.03' ,
description : 'money columns: dollars (REAL) -> integer cents' ,
run ( ) {
const conv = [
[ 'bills' , [ 'expected_amount' , 'current_balance' , 'minimum_payment' ] ] ,
[ 'payments' , [ 'amount' , 'balance_delta' , 'interest_delta' ] ] ,
[ 'monthly_bill_state' , [ 'actual_amount' ] ] ,
[ 'monthly_starting_amounts' , [ 'first_amount' , 'fifteenth_amount' , 'other_amount' ] ] ,
[ 'monthly_income' , [ 'amount' ] ] ,
[ 'spending_budgets' , [ 'amount' ] ] ,
[ 'snowball_plans' , [ 'extra_payment' ] ] ,
[ 'users' , [ 'snowball_extra_payment' ] ] ,
] ;
for ( const [ table , cols ] of conv ) {
for ( const col of cols ) {
db . exec ( ` UPDATE ${ table } SET ${ col } = CAST(ROUND( ${ col } * 100) AS INTEGER) WHERE ${ col } IS NOT NULL ` ) ;
}
}
console . log ( '[v1.03] money columns converted to integer cents' ) ;
}
} ,
{
version : 'v1.04' ,
description : 'bill_templates.data JSON: money fields dollars -> integer cents' ,
run ( ) {
// v1.03 converted table columns but not money values embedded in the
// bill_templates.data JSON blob. Templates saved before v1.03 hold
// dollars; the template code now reads cents (serializeTemplateData).
for ( const field of [ 'expected_amount' , 'current_balance' , 'minimum_payment' ] ) {
db . exec ( `
UPDATE bill _templates
SET data = json _set ( data , '$.${field}' ,
CAST ( ROUND ( json _extract ( data , '$.${field}' ) * 100 ) AS INTEGER ) )
WHERE json _extract ( data , '$.${field}' ) IS NOT NULL
` );
}
console . log ( '[v1.04] bill_templates.data money fields converted to integer cents' ) ;
}
} ,
2026-06-14 15:15:31 -05:00
{
version : 'v1.05' ,
description : 'merchant_store_matches: 5k merchant/store matching pack for bank transaction categorization' ,
dependsOn : [ 'v1.04' ] ,
run : function ( ) {
runMerchantStoreMatchMigration ( db ) ;
}
} ,
2026-06-14 19:21:34 -05:00
{
version : 'v1.06' ,
description : 'category_groups: organize spending categories into named groups' ,
dependsOn : [ 'v1.05' ] ,
run : function ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS category _groups (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
user _id INTEGER NOT NULL REFERENCES users ( id ) ON DELETE CASCADE ,
name TEXT NOT NULL ,
sort _order INTEGER NOT NULL DEFAULT 0 ,
created _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
updated _at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
UNIQUE ( user _id , name )
)
` );
const cols = db . prepare ( 'PRAGMA table_info(categories)' ) . all ( ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'group_id' ) )
db . exec ( 'ALTER TABLE categories ADD COLUMN group_id INTEGER REFERENCES category_groups(id) ON DELETE SET NULL' ) ;
console . log ( '[v1.06] category_groups table + categories.group_id added' ) ;
}
} ,
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-06-03 22:38:33 -05:00
// Store version list so reconcileLegacyMigrations() can assert sync.
_runMigrationVersions = migrations . map ( m => m . version ) ;
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 ) ;
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' ] ,
2026-06-06 17:00:22 -05:00
// Privacy settings (v0.94)
[ 'geolocation_enabled' , 'false' ] ,
2026-05-03 19:51:57 -05:00
] ;
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-16 20:26:09 -05:00
seedManualDataSources ( db ) ;
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 = {
2026-06-11 20:12:31 -05:00
'v1.04' : {
description : 'bill_templates.data JSON: money fields dollars -> integer cents' ,
sql : [
"UPDATE bill_templates SET data = json_set(data, '$.expected_amount', ROUND(json_extract(data, '$.expected_amount') / 100.0, 2)) WHERE json_extract(data, '$.expected_amount') IS NOT NULL" ,
"UPDATE bill_templates SET data = json_set(data, '$.current_balance', ROUND(json_extract(data, '$.current_balance') / 100.0, 2)) WHERE json_extract(data, '$.current_balance') IS NOT NULL" ,
"UPDATE bill_templates SET data = json_set(data, '$.minimum_payment', ROUND(json_extract(data, '$.minimum_payment') / 100.0, 2)) WHERE json_extract(data, '$.minimum_payment') IS NOT NULL" ,
]
} ,
'v1.03' : {
description : 'money columns: dollars (REAL) -> integer cents' ,
sql : [
'UPDATE bills SET expected_amount = ROUND(expected_amount / 100.0, 2) WHERE expected_amount IS NOT NULL' ,
'UPDATE bills SET current_balance = ROUND(current_balance / 100.0, 2) WHERE current_balance IS NOT NULL' ,
'UPDATE bills SET minimum_payment = ROUND(minimum_payment / 100.0, 2) WHERE minimum_payment IS NOT NULL' ,
'UPDATE payments SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL' ,
'UPDATE payments SET balance_delta = ROUND(balance_delta / 100.0, 2) WHERE balance_delta IS NOT NULL' ,
'UPDATE payments SET interest_delta = ROUND(interest_delta / 100.0, 2) WHERE interest_delta IS NOT NULL' ,
'UPDATE monthly_bill_state SET actual_amount = ROUND(actual_amount / 100.0, 2) WHERE actual_amount IS NOT NULL' ,
'UPDATE monthly_starting_amounts SET first_amount = ROUND(first_amount / 100.0, 2) WHERE first_amount IS NOT NULL' ,
'UPDATE monthly_starting_amounts SET fifteenth_amount = ROUND(fifteenth_amount / 100.0, 2) WHERE fifteenth_amount IS NOT NULL' ,
'UPDATE monthly_starting_amounts SET other_amount = ROUND(other_amount / 100.0, 2) WHERE other_amount IS NOT NULL' ,
'UPDATE monthly_income SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL' ,
'UPDATE spending_budgets SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL' ,
'UPDATE snowball_plans SET extra_payment = ROUND(extra_payment / 100.0, 2) WHERE extra_payment IS NOT NULL' ,
'UPDATE users SET snowball_extra_payment = ROUND(snowball_extra_payment / 100.0, 2) WHERE snowball_extra_payment IS NOT NULL' ,
]
} ,
2026-06-07 01:05:48 -05:00
'v0.98' : {
description : 'payments: bank override metadata for provisional manual payments' ,
sql : [
'DROP INDEX IF EXISTS idx_payments_overridden_by' ,
'DROP INDEX IF EXISTS idx_payments_accounting_active' ,
'ALTER TABLE payments DROP COLUMN IF EXISTS overridden_by_payment_id' ,
'ALTER TABLE payments DROP COLUMN IF EXISTS excluded_at' ,
'ALTER TABLE payments DROP COLUMN IF EXISTS exclusion_reason' ,
'ALTER TABLE payments DROP COLUMN IF EXISTS accounting_excluded' ,
]
} ,
2026-06-06 21:15:08 -05:00
'v0.97' : {
description : 'subscription recommendation feedback: per-user learning signals' ,
sql : [
'DROP TABLE IF EXISTS subscription_recommendation_feedback' ,
]
} ,
2026-06-06 20:02:13 -05:00
'v0.96' : {
description : 'bills: catalog_id FK; user_catalog_descriptors' ,
sql : [
'DROP TABLE IF EXISTS user_catalog_descriptors' ,
'ALTER TABLE bills DROP COLUMN IF EXISTS catalog_id' ,
]
} ,
'v0.95' : {
description : 'subscription_catalog: bank descriptors + pricing' ,
sql : [
'DROP TABLE IF EXISTS subscription_catalog_descriptors' ,
]
} ,
2026-06-06 17:00:22 -05:00
'v0.94' : {
description : 'security: session token hashing + geolocation opt-in setting' ,
sql : [
"DELETE FROM settings WHERE key = 'geolocation_enabled'" ,
// Sessions were cleared; rollback cannot restore them
]
} ,
2026-06-06 16:34:20 -05:00
'v0.93' : {
description : 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key' ,
sql : [
'ALTER TABLE bills DROP COLUMN IF EXISTS interest_accrued_month' ,
'ALTER TABLE payments DROP COLUMN IF EXISTS interest_delta' ,
// Restore the old (data_source_id, provider_transaction_id) dedupe index.
// The key format change and deleted duplicates cannot be reversed.
'DROP INDEX IF EXISTS idx_transactions_provider_dedupe' ,
` CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
ON transactions ( data _source _id , provider _transaction _id )
WHERE provider _transaction _id IS NOT NULL ` ,
]
} ,
2026-05-10 10:44:39 -05:00
'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-14 02:11:54 -05:00
} ,
'v0.48' : {
description : 'bills: debt snowball fields' ,
sql : [
'ALTER TABLE bills DROP COLUMN snowball_include' ,
'ALTER TABLE bills DROP COLUMN snowball_order' ,
'ALTER TABLE bills DROP COLUMN minimum_payment' ,
'ALTER TABLE bills DROP COLUMN current_balance' ,
]
} ,
'v0.49' : {
description : 'users: snowball extra payment field' ,
sql : [ 'ALTER TABLE users DROP COLUMN snowball_extra_payment' ]
} ,
'v0.50' : {
description : 'payments: balance_delta column' ,
sql : [ 'ALTER TABLE payments DROP COLUMN balance_delta' ]
2026-05-14 03:00:01 -05:00
} ,
2026-05-16 20:26:09 -05:00
'v0.59' : {
description : 'payments: source metadata columns' ,
sql : [
'ALTER TABLE payments DROP COLUMN transaction_id' ,
'ALTER TABLE payments DROP COLUMN payment_source' ,
]
} ,
'v0.60' : {
description : 'transactions: shared transaction foundation tables' ,
sql : [
'DROP INDEX IF EXISTS idx_transactions_provider_dedupe' ,
'DROP INDEX IF EXISTS idx_transactions_matched_bill' ,
'DROP INDEX IF EXISTS idx_transactions_account' ,
'DROP INDEX IF EXISTS idx_transactions_user_match' ,
'DROP INDEX IF EXISTS idx_transactions_user_date' ,
'DROP INDEX IF EXISTS idx_financial_accounts_user_source' ,
'DROP INDEX IF EXISTS idx_data_sources_user_manual' ,
'DROP INDEX IF EXISTS idx_data_sources_user_type' ,
'DROP TABLE IF EXISTS transactions' ,
'DROP TABLE IF EXISTS financial_accounts' ,
'DROP TABLE IF EXISTS data_sources' ,
]
} ,
2026-05-16 21:36:04 -05:00
'v0.61' : {
description : 'payments: one active payment per linked transaction' ,
sql : [ 'DROP INDEX IF EXISTS idx_payments_transaction_active' ]
} ,
'v0.62' : {
description : 'matches: rejected transaction match suggestions' ,
sql : [
'DROP INDEX IF EXISTS idx_match_suggestion_rejections_user' ,
'DROP TABLE IF EXISTS match_suggestion_rejections' ,
]
} ,
2026-05-28 22:54:07 -05:00
'v0.63' : {
description : 'bills: subscription metadata fields' ,
sql : [
'DROP INDEX IF EXISTS idx_bills_user_subscription' ,
'ALTER TABLE bills DROP COLUMN subscription_detected_at' ,
'ALTER TABLE bills DROP COLUMN subscription_source' ,
'ALTER TABLE bills DROP COLUMN reminder_days_before' ,
'ALTER TABLE bills DROP COLUMN subscription_type' ,
'ALTER TABLE bills DROP COLUMN is_subscription' ,
]
} ,
2026-05-30 16:13:37 -05:00
'v0.72' : {
description : 'bills: persistent tracker sort order' ,
sql : [
'DROP INDEX IF EXISTS idx_bills_user_sort' ,
'ALTER TABLE bills DROP COLUMN sort_order' ,
]
} ,
2026-05-30 20:04:50 -05:00
'v0.75' : {
description : 'categories: persistent sort order' ,
sql : [
'DROP INDEX IF EXISTS idx_categories_user_sort' ,
'ALTER TABLE categories DROP COLUMN sort_order' ,
]
} ,
2026-05-30 21:20:51 -05:00
'v0.76' : {
description : 'bills: canonical billing schedule cleanup' ,
sql : [
` UPDATE bills
SET billing _cycle = CASE
WHEN cycle _type = 'quarterly' THEN 'quarterly'
WHEN cycle _type = 'annual' THEN 'annually'
WHEN cycle _type IN ( 'weekly' , 'biweekly' ) THEN 'irregular'
ELSE 'monthly'
END ` ,
]
} ,
2026-05-14 03:00:01 -05:00
'v0.51' : {
description : 'bills: snowball_exempt column' ,
sql : [ 'ALTER TABLE bills DROP COLUMN snowball_exempt' ]
2026-05-14 21:00:07 -05:00
} ,
'v0.52' : {
description : 'users: last_seen_version column' ,
sql : [ 'ALTER TABLE users DROP COLUMN last_seen_version' ]
2026-05-15 01:36:56 -05:00
} ,
'v0.53' : {
description : 'user_login_history table' ,
sql : [ 'DROP TABLE IF EXISTS user_login_history' ]
2026-05-15 22:45:38 -05:00
} ,
'v0.54' : {
description : 'user_settings table' ,
sql : [ 'DROP TABLE IF EXISTS user_settings' ]
} ,
'v0.55' : {
description : 'user_login_history device metadata columns' ,
sql : [
'ALTER TABLE user_login_history DROP COLUMN device_fingerprint' ,
'ALTER TABLE user_login_history DROP COLUMN device_type' ,
'ALTER TABLE user_login_history DROP COLUMN os' ,
'ALTER TABLE user_login_history DROP COLUMN browser' ,
]
2026-05-16 10:34:32 -05:00
} ,
'v0.56' : {
description : 'bills/categories soft-delete columns' ,
sql : [
'DROP INDEX IF EXISTS idx_categories_deleted' ,
'DROP INDEX IF EXISTS idx_bills_deleted' ,
'ALTER TABLE categories DROP COLUMN deleted_at' ,
'ALTER TABLE bills DROP COLUMN deleted_at' ,
]
2026-05-16 15:38:28 -05:00
} ,
'v0.57' : {
description : 'autopay suggestions and auto-mark paid' ,
sql : [
'DROP INDEX IF EXISTS idx_autopay_suggestion_dismissals_user_month' ,
'DROP TABLE IF EXISTS autopay_suggestion_dismissals' ,
'ALTER TABLE bills DROP COLUMN auto_mark_paid' ,
]
} ,
'v0.58' : {
description : 'saved bill templates' ,
sql : [
'DROP INDEX IF EXISTS idx_bill_templates_user_name' ,
'DROP TABLE IF EXISTS bill_templates' ,
]
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 } ;