refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
// @ts-nocheck — .cts via Node type-stripping; rich typing deferred (large dynamic DB/migration infra).
2026-05-03 19:51:57 -05:00
const Database = require ( 'better-sqlite3' ) ;
2026-07-06 16:05:18 -05:00
const { log } = require ( '../utils/logger.cts' ) ;
2026-05-03 19:51:57 -05:00
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 ) {
2026-07-06 14:23:53 -05:00
try {
_logAudit = require ( '../services/auditService.cts' ) . logAudit ;
} catch {
_logAudit = ( ) = > { } ;
}
2026-05-10 09:45:39 -05:00
}
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
2026-07-06 14:23:53 -05:00
'active' ,
'is_default_admin' ,
'notification_email' ,
'notifications_enabled' ,
'notify_3d' ,
'notify_1d' ,
'notify_due' ,
'notify_overdue' ,
'notify_amount_change' ,
'display_name' ,
'last_password_change_at' ,
'auth_provider' ,
'external_subject' ,
'email' ,
'last_login_at' ,
2026-05-09 13:03:36 -05:00
// payments table columns
2026-07-06 14:23:53 -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
2026-07-06 14:23:53 -05:00
'history_visibility' ,
'interest_rate' ,
'user_id' ,
'current_balance' ,
'minimum_payment' ,
'snowball_order' ,
'snowball_include' ,
'sort_order' ,
'snowball_exempt' ,
'is_subscription' ,
'subscription_type' ,
'reminder_days_before' ,
'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).
2026-07-06 14:23:53 -05:00
const {
SUBSCRIPTION_CATALOG_ROWS ,
SUBSCRIPTION_CATALOG_V2_ROWS ,
} = require ( './subscriptionCatalogSeed.cts' ) ;
2026-07-03 13:31:00 -05:00
// The versioned migration list lives in its own module (a factory injected with db + helpers).
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const buildVersionedMigrations = require ( './migrations/versionedMigrations.cts' ) ;
2026-07-03 13:34:01 -05:00
// The legacy-reconcile migration list (same factory pattern, legacy DBs only).
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const buildLegacyReconcileMigrations = require ( './migrations/legacyReconcileMigrations.cts' ) ;
2026-05-29 18:34:50 -05:00
function runSubscriptionCatalogV2Migration ( database ) {
// Category fixes for existing rows
2026-07-06 14:23:53 -05:00
database
. prepare (
`
2026-05-29 18:34:50 -05:00
UPDATE subscription_catalog SET subscription_type = 'software'
WHERE name = 'Discord Nitro' AND subscription_type = 'news'
2026-07-06 14:23:53 -05:00
` ,
)
. run ( ) ;
database
. prepare (
`
2026-05-29 18:34:50 -05:00
UPDATE subscription_catalog SET subscription_type = 'streaming'
WHERE name = 'Twitch Turbo' AND subscription_type = 'news'
2026-07-06 14:23:53 -05:00
` ,
)
. run ( ) ;
database
. prepare (
`
2026-05-29 18:34:50 -05:00
UPDATE subscription_catalog SET subscription_type = 'software'
WHERE name = 'X Premium' AND subscription_type = 'news'
2026-07-06 14:23:53 -05:00
` ,
)
. run ( ) ;
2026-05-29 18:34:50 -05:00
// New entries — skip any name already in the catalog
const existing = new Set (
2026-07-06 14:23:53 -05:00
database
. prepare ( 'SELECT name FROM subscription_catalog' )
. all ( )
. map ( ( r ) = > r . name ) ,
2026-05-29 18:34:50 -05:00
) ;
2026-07-06 14:23:53 -05:00
const toInsert = SUBSCRIPTION_CATALOG_V2_ROWS . filter ( ( r ) = > ! existing . has ( r [ 1 ] ) ) ;
2026-05-29 18:34:50 -05:00
if ( toInsert . length > 0 ) {
const insert = database . prepare (
2026-07-06 14:23:53 -05:00
'INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) VALUES (?,?,?,?,?,?)' ,
2026-05-29 18:34:50 -05:00
) ;
const insertMany = database . transaction ( ( rows ) = > {
for ( const row of rows ) insert . run ( . . . row ) ;
} ) ;
insertMany ( toInsert ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] subscription_catalog v2: added ${ toInsert . length } new entries ` ) ;
2026-05-29 18:34:50 -05:00
}
}
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 ) {
2026-07-06 14:23:53 -05:00
const jsonPath = path . join (
__dirname ,
2026-07-10 18:15:04 -05:00
'data' ,
2026-07-06 14:23:53 -05:00
'advisory_non_bill_transaction_filters_us_ms_5000.json' ,
) ;
2026-05-29 18:06:12 -05:00
const raw = fs . readFileSync ( jsonPath , 'utf8' ) ;
const data = JSON . parse ( raw ) ;
const insertFilter = database . prepare (
2026-07-06 14:23:53 -05:00
'INSERT INTO advisory_non_bill_filters (id, pattern, confidence, category, rationale) VALUES (?,?,?,?,?)' ,
2026-05-29 18:06:12 -05:00
) ;
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 || [ ] ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] advisory_non_bill_filters: seeded ${ ( data . patterns || [ ] ) . length } rows ` ) ;
2026-05-29 18:06:12 -05:00
const overrideTerms = data . bill_like_override_terms || [ ] ;
if ( overrideTerms . length > 0 ) {
const insertOverride = database . prepare (
2026-07-06 14:23:53 -05:00
'INSERT OR IGNORE INTO advisory_bill_like_overrides (term) VALUES (?)' ,
2026-05-29 18:06:12 -05:00
) ;
const insertOverrides = database . transaction ( ( terms ) = > {
for ( const term of terms ) insertOverride . run ( term ) ;
} ) ;
insertOverrides ( overrideTerms ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] advisory_bill_like_overrides: seeded ${ overrideTerms . length } rows ` ) ;
2026-05-29 18:06:12 -05:00
}
}
}
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 ) {
2026-07-06 14:23:53 -05:00
const jsonPath = path . join (
__dirname ,
2026-07-10 18:15:04 -05:00
'data' ,
2026-07-06 14:23:53 -05:00
'merchant_store_match_us_nems_online_5k_v0_2.json' ,
) ;
2026-06-14 15:15:31 -05:00
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 || [ ] ) ;
2026-07-06 16:05:18 -05:00
log . info (
2026-07-06 14:23:53 -05:00
` [migration] merchant_store_matches: seeded ${ ( data . merchant_store_entries || [ ] ) . length } rows ` ,
) ;
2026-06-14 15:15:31 -05:00
}
}
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 (
2026-07-06 14:23:53 -05:00
'INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) VALUES (?,?,?,?,?,?)' ,
2026-05-29 01:51:42 -05:00
) ;
const insertMany = database . transaction ( ( rows ) = > {
for ( const row of rows ) insert . run ( . . . row ) ;
} ) ;
insertMany ( SUBSCRIPTION_CATALOG_ROWS ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] subscription_catalog: seeded ${ SUBSCRIPTION_CATALOG_ROWS . length } rows ` ) ;
2026-05-29 01:51:42 -05:00
}
}
2026-05-16 20:26:09 -05:00
function seedManualDataSources ( database = db ) {
if ( ! database ) return ;
2026-07-06 14:23:53 -05:00
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 ( ) ;
2026-05-16 20:26:09 -05:00
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-07-06 16:05:18 -05:00
log . info ( 'Opening DB at:' , path . basename ( DB_PATH ) ) ;
2026-05-03 19:51:57 -05:00
assertWritableDbPath ( ) ;
db = new Database ( DB_PATH , {
2026-07-06 14:23:53 -05:00
timeout : 5000 ,
2026-05-03 19:51:57 -05:00
} ) ;
db . pragma ( 'busy_timeout = 5000' ) ;
try {
db . pragma ( 'journal_mode = WAL' ) ;
} catch ( e ) {
2026-07-06 16:05:18 -05:00
log . warn ( 'WAL failed:' , e . message ) ;
2026-05-03 19:51:57 -05:00
}
db . pragma ( 'foreign_keys = ON' ) ;
initSchema ( ) ;
seedDefaults ( ) ;
2026-07-06 16:05:18 -05:00
log . info ( 'DB initialized successfully' ) ;
2026-05-03 19:51:57 -05:00
return db ;
} catch ( err ) {
2026-07-06 16:05:18 -05:00
log . error ( 'DB init failed:' , err ) ;
2026-05-03 19:51:57 -05:00
throw err ;
} finally {
initializing = false ;
}
}
function initSchema() {
const schema = fs . readFileSync ( SCHEMA_PATH , 'utf8' ) ;
db . exec ( schema ) ;
2026-07-06 14:23:53 -05:00
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-07-06 14:23:53 -05:00
2026-05-09 18:25:25 -05:00
// Check if this is a legacy database (tables exist but no migration tracking)
handleLegacyDatabase ( ) ;
2026-07-06 14:23:53 -05:00
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 ) ;
2026-07-06 14:23:53 -05:00
2026-05-09 19:47:00 -05:00
// Reset password for the default admin user if INIT_ADMIN_PASS is set
2026-07-06 14:23:53 -05:00
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
2026-07-06 14:23:53 -05:00
` ,
)
. run ( newPasswordHash , initUser ) ;
2026-05-09 19:47:00 -05:00
if ( result . changes > 0 ) {
2026-07-06 16:05:18 -05:00
log . info ( '[init] Reset password and flags for default admin user' ) ;
2026-05-09 19:47:00 -05:00
}
}
2026-07-06 14:23:53 -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 ;
2026-07-06 14:23:53 -05:00
2026-05-09 18:25:25 -05:00
if ( migrationCount === 0 ) {
// This might be a legacy database. Check if core tables exist.
2026-07-06 14:23:53 -05:00
const tableCheck = db
. prepare (
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users', 'bills', 'payments', 'categories', 'settings')" ,
)
. all ( ) ;
2026-05-09 18:25:25 -05:00
// If we have core tables but no migrations tracked, this is likely a legacy DB
2026-07-06 14:23:53 -05:00
if ( tableCheck . length >= 3 ) {
// At least some core tables exist
2026-07-06 16:05:18 -05:00
log . info ( '[migration] Detected legacy database, reconciling schema migrations...' ) ;
2026-07-06 14:23:53 -05:00
2026-05-09 18:25:25 -05:00
// 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
2026-07-03 13:34:01 -05:00
const migrations = buildLegacyReconcileMigrations ( {
db ,
isValidColumnName ,
isValidSqlDefinition ,
ensureTransactionFoundationSchema ,
runSubscriptionCatalogMigration ,
runSubscriptionCatalogV2Migration ,
runAdvisoryFiltersMigration ,
runMerchantStoreMatchMigration ,
} ) ;
2026-05-14 01:17:05 -05:00
2026-05-09 18:25:25 -05:00
// Check for legacy notification columns
2026-07-06 14:23:53 -05:00
const userCols = db
. prepare ( 'PRAGMA table_info(users)' )
. all ( )
. map ( ( c ) = > c . name ) ;
2026-05-09 18:25:25 -05:00
const newUserCols = [
2026-07-06 14:23:53 -05:00
'active' ,
'is_default_admin' ,
'notification_email' ,
'notifications_enabled' ,
'notify_3d' ,
'notify_1d' ,
'notify_due' ,
'notify_overdue' ,
2026-05-09 18:25:25 -05:00
] ;
2026-07-06 14:23:53 -05:00
const hasNotificationColumns = newUserCols . every ( ( col ) = > userCols . includes ( col ) ) ;
2026-05-09 18:25:25 -05:00
// If notification columns exist, mark that migration as applied
if ( hasNotificationColumns ) {
try {
recordMigration ( 'legacy-notification-columns' , 'users: notification columns' ) ;
2026-07-06 16:05:18 -05:00
log . info ( '[migration] Recorded legacy notification columns migration' ) ;
2026-05-09 18:25:25 -05:00
} catch ( e ) {
// Ignore if already recorded
}
}
2026-07-06 14:23:53 -05:00
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 ) {
2026-07-06 14:23:53 -05:00
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 ) ) ;
2026-06-03 22:38:33 -05:00
if ( onlyInRun . length || onlyInReconcile . length ) {
const msg =
'[migration-sync] reconcileLegacyMigrations and runMigrations version lists are out of sync.' +
2026-07-06 14:23:53 -05:00
( onlyInRun . length ? ` Only in runMigrations: ${ onlyInRun . join ( ', ' ) } . ` : '' ) +
2026-06-03 22:38:33 -05:00
( onlyInReconcile . length ? ` Only in reconcile: ${ onlyInReconcile . join ( ', ' ) } . ` : '' ) +
' Add the missing version to both arrays.' ;
2026-07-06 16:05:18 -05:00
log . error ( msg ) ;
2026-06-03 22:38:33 -05:00
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 ) ;
2026-07-06 16:05:18 -05:00
log . info (
2026-07-06 14:23:53 -05:00
` [migration] Recorded legacy migration ${ migration . version } : ${ migration . description } ` ,
) ;
2026-05-09 18:25:25 -05:00
} 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 {
2026-07-06 16:05:18 -05:00
log . info (
2026-07-06 14:23:53 -05:00
` [migration] Running legacy migration ${ migration . version } : ${ migration . description } ` ,
) ;
2026-05-09 22:34:50 -05:00
// Wrap legacy migration in transaction
db . exec ( 'BEGIN' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [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' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [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' ) ;
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [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
}
}
2026-07-06 14:23:53 -05:00
2026-07-06 16:05:18 -05:00
log . info ( '[migration] Legacy database reconciliation complete' ) ;
2026-05-09 18:25:25 -05:00
}
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 ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] Applied ${ version } : ${ description } ` ) ;
2026-05-09 15:17:40 -05:00
}
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 || [ ] ;
2026-07-06 14:23:53 -05:00
const missing = deps . filter ( ( dep ) = > ! appliedVersions . has ( dep ) ) ;
2026-05-09 23:24:51 -05:00
if ( missing . length === 0 ) {
return { valid : true } ;
}
return { valid : false , missing } ;
}
2026-05-03 19:51:57 -05:00
function runMigrations() {
2026-07-06 16:05:18 -05:00
log . info ( '[migration] Starting database migrations' ) ;
2026-05-10 09:45:39 -05:00
const startTime = Date . now ( ) ;
2026-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// Log start of migrations to audit log
try {
getLogAudit ( ) ( {
action : 'migration.start' ,
entity_type : 'migration' ,
entity_id : null ,
2026-07-06 14:23:53 -05:00
details : { message : 'Starting database migrations' } ,
2026-05-10 09:45:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error ( ` [audit-error] Failed to log migration start to audit log: ${ auditErr . message } ` ) ;
2026-05-10 09:45:39 -05:00
}
2026-05-09 23:24:51 -05:00
// Define all migrations with explicit version tracking and dependency chains
2026-07-03 13:31:00 -05:00
const migrations = buildVersionedMigrations ( {
db ,
isValidColumnName ,
isValidSqlDefinition ,
ensureTransactionFoundationSchema ,
runSubscriptionCatalogMigration ,
runSubscriptionCatalogV2Migration ,
runAdvisoryFiltersMigration ,
runMerchantStoreMatchMigration ,
} ) ;
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-07-06 16:05:18 -05:00
log . info ( '[migration] Applying unversioned user notification columns' ) ;
2026-05-10 09:45:39 -05:00
const unversionedStartTime = Date . now ( ) ;
2026-07-06 14:23:53 -05:00
2026-05-09 22:34:50 -05:00
try {
db . exec ( 'BEGIN' ) ;
2026-07-06 16:05:18 -05:00
log . info ( '[migration] Transaction BEGIN for unversioned user notification columns' ) ;
2026-07-06 14:23:53 -05:00
const userCols = db
. prepare ( 'PRAGMA table_info(users)' )
. all ( )
. map ( ( c ) = > c . name ) ;
2026-05-09 22:34:50 -05:00
const newUserCols = [
2026-07-06 14:23:53 -05:00
[ 'active' , 'INTEGER NOT NULL DEFAULT 1' ] ,
[ 'is_default_admin' , 'INTEGER NOT NULL DEFAULT 0' ] ,
[ 'notification_email' , 'TEXT' ] ,
2026-05-09 22:34:50 -05:00
[ 'notifications_enabled' , 'INTEGER NOT NULL DEFAULT 0' ] ,
2026-07-06 14:23:53 -05:00
[ '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' ] ,
2026-05-09 22:34:50 -05:00
] ;
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' ;
2026-07-06 14:23:53 -05:00
db . prepare (
`
2026-05-09 22:34:50 -05:00
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 )
2026-07-06 14:23:53 -05:00
` ,
) . run ( defaultAdminName ) ;
2026-05-09 22:34:50 -05:00
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' ) ;
2026-07-06 16:05:18 -05:00
log . info ( '[migration] Transaction COMMIT for unversioned user notification columns' ) ;
2026-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - unversionedStartTime ;
2026-07-06 16:05:18 -05:00
log . info ( ` [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 ;
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [migration-error] Failed to apply unversioned user notification columns after ${ elapsed } ms: ${ err . message } . Rolled back. ` ,
) ;
2026-05-10 09:45:39 -05:00
// 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 ,
2026-07-06 14:23:53 -05:00
elapsed_ms : elapsed ,
} ,
2026-05-10 09:45:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error ( ` [audit-error] Failed to log migration failure to audit log: ${ auditErr . message } ` ) ;
2026-05-10 09:45:39 -05:00
}
2026-07-06 14:23:53 -05:00
2026-05-09 22:34:50 -05:00
throw err ;
}
2026-07-06 14:23:53 -05:00
2026-06-03 22:38:33 -05:00
// Store version list so reconcileLegacyMigrations() can assert sync.
2026-07-06 14:23:53 -05:00
_runMigrationVersions = migrations . map ( ( m ) = > m . version ) ;
2026-06-03 22:38:33 -05:00
2026-05-09 23:24:51 -05:00
// Build set of already-applied versions for dependency checking
const appliedVersions = new Set (
2026-07-06 14:23:53 -05:00
db
. prepare ( 'SELECT version FROM schema_migrations' )
. all ( )
. map ( ( r ) = > r . version ) ,
2026-05-09 23:24:51 -05:00
) ;
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 ) {
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [migration-error] ${ migration . version } depends on [ ${ depCheck . missing . join ( ', ' ) } ] which have not been applied. Skipping. ` ,
) ;
2026-05-09 23:24:51 -05:00
continue ;
}
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] Applying ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 23:24:51 -05:00
if ( migration . dependsOn && migration . dependsOn . length > 0 ) {
2026-07-06 16:05:18 -05:00
log . info (
2026-07-06 14:23:53 -05:00
` [migration] ${ migration . version } depends on [ ${ migration . dependsOn . join ( ', ' ) } ] — satisfied ` ,
) ;
2026-05-09 23:24:51 -05:00
}
2026-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// Timing for migration execution
const migrationStartTime = Date . now ( ) ;
2026-07-06 14:23:53 -05:00
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.
2026-07-06 14:23:53 -05:00
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' ) ;
2026-05-09 22:34:50 -05:00
if ( needsForeignKeyOff ) {
db . exec ( 'PRAGMA foreign_keys = OFF' ) ;
}
try {
db . exec ( 'BEGIN' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] Transaction BEGIN for ${ migration . version } ` ) ;
2026-05-09 22:34:50 -05:00
migration . run ( ) ;
recordMigration ( migration . version , migration . description ) ;
db . exec ( 'COMMIT' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] Transaction COMMIT for ${ migration . version } ` ) ;
2026-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - migrationStartTime ;
2026-07-06 16:05:18 -05:00
log . info ( ` [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 ;
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [migration-error] ${ migration . version } failed after ${ elapsed } ms: ${ innerErr . message } . Rolled back. ` ,
) ;
2026-05-10 09:45:39 -05:00
// 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 ,
2026-07-06 14:23:53 -05:00
elapsed_ms : elapsed ,
} ,
2026-05-10 09:45:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [audit-error] Failed to log migration failure to audit log: ${ auditErr . message } ` ,
) ;
2026-05-10 09:45:39 -05:00
}
2026-07-06 14:23:53 -05:00
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' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] Transaction BEGIN for ${ migration . version } ` ) ;
2026-05-09 22:34:50 -05:00
migration . run ( ) ;
recordMigration ( migration . version , migration . description ) ;
db . exec ( 'COMMIT' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] Transaction COMMIT for ${ migration . version } ` ) ;
2026-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - migrationStartTime ;
2026-07-06 16:05:18 -05:00
log . info ( ` [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 ;
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [migration-error] Failed to apply ${ migration . version } after ${ elapsed } ms: ${ err . message } . Rolled back. ` ,
) ;
2026-05-10 09:45:39 -05:00
// 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 ,
2026-07-06 14:23:53 -05:00
elapsed_ms : elapsed ,
} ,
2026-05-10 09:45:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error (
2026-07-06 14:23:53 -05:00
` [audit-error] Failed to log migration failure to audit log: ${ auditErr . message } ` ,
) ;
2026-05-10 09:45:39 -05:00
}
2026-07-06 14:23:53 -05:00
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 {
2026-07-06 16:05:18 -05:00
log . info (
2026-07-06 14:23:53 -05:00
` [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-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// Log total migration time
2026-07-06 14:23:53 -05:00
2026-05-10 09:45:39 -05:00
// 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-07-06 14:23:53 -05:00
message : 'All migrations completed successfully' ,
} ,
2026-05-10 10:44:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error ( ` [audit-error] Failed to log migration completion to audit log: ${ auditErr . message } ` ) ;
2026-05-10 09:45:39 -05:00
}
const totalTime = Date . now ( ) - startTime ;
2026-07-06 16:05:18 -05:00
log . info ( ` [migration] All migrations completed in ${ totalTime } ms ` ) ;
2026-07-06 14:23:53 -05:00
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)
2026-07-06 14:23:53 -05:00
[ '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' ] ,
2026-05-03 19:51:57 -05:00
[ 'cleanup_import_history_max_age_days' , '365' ] ,
2026-07-06 14:23:53 -05:00
[ 'cleanup_last_run_at' , '' ] ,
[ 'cleanup_last_result' , '' ] ,
2026-05-03 19:51:57 -05:00
// Auth method settings (v0.18)
2026-07-06 14:23:53 -05:00
[ 'local_login_enabled' , 'true' ] ,
[ 'oidc_login_enabled' , 'false' ] ,
[ 'oidc_provider_name' , 'authentik' ] ,
[ 'oidc_issuer_url' , '' ] ,
[ 'oidc_client_id' , '' ] ,
[ 'oidc_client_secret' , '' ] ,
2026-05-03 19:51:57 -05:00
[ 'oidc_token_auth_method' , 'client_secret_basic' ] ,
2026-07-06 14:23:53 -05:00
[ '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)
2026-07-06 14:23:53 -05:00
[ 'geolocation_enabled' , 'false' ] ,
2026-05-03 19:51:57 -05:00
] ;
2026-07-06 14:23:53 -05:00
const insert = db . prepare ( 'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)' ) ;
2026-05-03 19:51:57 -05:00
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 ) ;
2026-07-06 14:23:53 -05:00
db . prepare (
`
2026-05-09 13:03:36 -05:00
INSERT INTO users ( username , password_hash , role , is_default_admin , active , email , created_at , updated_at )
VALUES ( ? , ? , 'admin' , 1 , 1 , ? , datetime ( 'now' ) , datetime ( 'now' ) )
2026-07-06 14:23:53 -05:00
` ,
) . run ( initUser , password_hash , initUser + '@local' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [seed] Created initial admin user: ${ initUser } ` ) ;
2026-05-09 13:03:36 -05:00
}
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 ) {
2026-07-06 14:23:53 -05:00
const existing = db
. prepare ( 'SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE' )
2026-05-03 19:51:57 -05:00
. 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 (
2026-07-06 14:23:53 -05:00
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))" ,
2026-05-03 19:51:57 -05:00
) . 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" ,
2026-07-06 14:23:53 -05:00
] ,
2026-06-11 20:12:31 -05:00
} ,
'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-07-06 14:23:53 -05:00
] ,
2026-06-11 20:12:31 -05:00
} ,
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-07-06 14:23:53 -05:00
] ,
2026-06-07 01:05:48 -05:00
} ,
2026-06-06 21:15:08 -05:00
'v0.97' : {
description : 'subscription recommendation feedback: per-user learning signals' ,
2026-07-06 14:23:53 -05:00
sql : [ 'DROP TABLE IF EXISTS subscription_recommendation_feedback' ] ,
2026-06-06 21:15:08 -05:00
} ,
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' ,
2026-07-06 14:23:53 -05:00
] ,
2026-06-06 20:02:13 -05:00
} ,
'v0.95' : {
description : 'subscription_catalog: bank descriptors + pricing' ,
2026-07-06 14:23:53 -05:00
sql : [ 'DROP TABLE IF EXISTS subscription_catalog_descriptors' ] ,
2026-06-06 20:02:13 -05:00
} ,
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-07-06 14:23:53 -05:00
] ,
2026-06-06 17:00:22 -05:00
} ,
2026-06-06 16:34:20 -05:00
'v0.93' : {
2026-07-06 14:23:53 -05:00
description :
'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key' ,
2026-06-06 16:34:20 -05:00
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-07-06 14:23:53 -05:00
] ,
2026-06-06 16:34:20 -05:00
} ,
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' ,
2026-07-06 14:23:53 -05:00
'DROP INDEX IF EXISTS idx_import_history_imported_at' ,
] ,
2026-05-10 10:44:39 -05:00
} ,
'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' ,
2026-07-06 14:23:53 -05:00
'DROP TABLE IF EXISTS audit_log' ,
] ,
2026-05-10 10:44:39 -05:00
} ,
'v0.46' : {
description : 'billing: add cycle_type and cycle_day columns to bills' ,
2026-07-06 14:23:53 -05:00
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 : [
2026-07-06 14:23:53 -05:00
"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' ,
2026-07-06 14:23:53 -05:00
] ,
2026-05-14 02:11:54 -05:00
} ,
'v0.49' : {
description : 'users: snowball extra payment field' ,
2026-07-06 14:23:53 -05:00
sql : [ 'ALTER TABLE users DROP COLUMN snowball_extra_payment' ] ,
2026-05-14 02:11:54 -05:00
} ,
'v0.50' : {
description : 'payments: balance_delta column' ,
2026-07-06 14:23:53 -05:00
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' ,
2026-07-06 14:23:53 -05:00
] ,
2026-05-16 20:26:09 -05:00
} ,
'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-07-06 14:23:53 -05:00
] ,
2026-05-16 20:26:09 -05:00
} ,
2026-05-16 21:36:04 -05:00
'v0.61' : {
description : 'payments: one active payment per linked transaction' ,
2026-07-06 14:23:53 -05:00
sql : [ 'DROP INDEX IF EXISTS idx_payments_transaction_active' ] ,
2026-05-16 21:36:04 -05:00
} ,
'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-07-06 14:23:53 -05:00
] ,
2026-05-16 21:36:04 -05:00
} ,
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-07-06 14:23:53 -05:00
] ,
2026-05-28 22:54:07 -05:00
} ,
2026-05-30 16:13:37 -05:00
'v0.72' : {
description : 'bills: persistent tracker sort order' ,
2026-07-06 14:23:53 -05:00
sql : [ 'DROP INDEX IF EXISTS idx_bills_user_sort' , 'ALTER TABLE bills DROP COLUMN sort_order' ] ,
2026-05-30 16:13:37 -05:00
} ,
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-07-06 14:23:53 -05:00
] ,
2026-05-30 20:04:50 -05:00
} ,
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-07-06 14:23:53 -05:00
] ,
2026-05-30 21:20:51 -05:00
} ,
2026-05-14 03:00:01 -05:00
'v0.51' : {
description : 'bills: snowball_exempt column' ,
2026-07-06 14:23:53 -05:00
sql : [ 'ALTER TABLE bills DROP COLUMN snowball_exempt' ] ,
2026-05-14 21:00:07 -05:00
} ,
'v0.52' : {
description : 'users: last_seen_version column' ,
2026-07-06 14:23:53 -05:00
sql : [ 'ALTER TABLE users DROP COLUMN last_seen_version' ] ,
2026-05-15 01:36:56 -05:00
} ,
'v0.53' : {
description : 'user_login_history table' ,
2026-07-06 14:23:53 -05:00
sql : [ 'DROP TABLE IF EXISTS user_login_history' ] ,
2026-05-15 22:45:38 -05:00
} ,
'v0.54' : {
description : 'user_settings table' ,
2026-07-06 14:23:53 -05:00
sql : [ 'DROP TABLE IF EXISTS user_settings' ] ,
2026-05-15 22:45:38 -05:00
} ,
'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-07-06 14:23:53 -05:00
] ,
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-07-06 14:23:53 -05:00
] ,
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' ,
2026-07-06 14:23:53 -05:00
] ,
2026-05-16 15:38:28 -05:00
} ,
'v0.58' : {
description : 'saved bill templates' ,
sql : [
'DROP INDEX IF EXISTS idx_bill_templates_user_name' ,
'DROP TABLE IF EXISTS bill_templates' ,
2026-07-06 14:23:53 -05:00
] ,
} ,
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 ;
}
2026-07-06 16:05:18 -05:00
log . info ( ` [rollback] Rolling back ${ version } : ${ rollback . description } ` ) ;
2026-05-10 10:44:39 -05:00
const startTime = Date . now ( ) ;
try {
db . exec ( 'BEGIN' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [rollback] Transaction BEGIN for ${ version } ` ) ;
2026-05-10 10:44:39 -05:00
for ( const stmt of rollback . sql ) {
2026-07-06 16:05:18 -05:00
log . info ( ` [rollback] Executing: ${ stmt } ` ) ;
2026-05-10 10:44:39 -05:00
db . exec ( stmt ) ;
}
// Remove migration record
db . prepare ( 'DELETE FROM schema_migrations WHERE version = ?' ) . run ( version ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [rollback] Removed ${ version } from schema_migrations ` ) ;
2026-05-10 10:44:39 -05:00
db . exec ( 'COMMIT' ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [rollback] Transaction COMMIT for ${ version } ` ) ;
2026-05-10 10:44:39 -05:00
const elapsed = Date . now ( ) - startTime ;
2026-07-06 16:05:18 -05:00
log . info ( ` [rollback] ${ version } rolled back in ${ elapsed } ms ` ) ;
2026-05-10 10:44:39 -05:00
// Audit log
try {
getLogAudit ( ) ( {
action : 'migration.rollback' ,
entity_type : 'migration' ,
entity_id : null ,
2026-07-06 14:23:53 -05:00
details : { version , description : rollback.description , elapsed_ms : elapsed } ,
2026-05-10 10:44:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error ( ` [audit-error] Failed to log rollback to audit log: ${ auditErr . message } ` ) ;
2026-05-10 10:44:39 -05:00
}
return { success : true , version , description : rollback.description , elapsed_ms : elapsed } ;
} catch ( err ) {
db . exec ( 'ROLLBACK' ) ;
const elapsed = Date . now ( ) - startTime ;
2026-07-06 16:05:18 -05:00
log . error ( ` [rollback-error] ${ version } failed after ${ elapsed } ms: ${ err . message } ` ) ;
2026-05-10 10:44:39 -05:00
// Audit log
try {
getLogAudit ( ) ( {
action : 'migration.rollback.failure' ,
entity_type : 'migration' ,
entity_id : null ,
2026-07-06 14:23:53 -05:00
details : {
version ,
description : rollback.description ,
error : err.message ,
elapsed_ms : elapsed ,
} ,
2026-05-10 10:44:39 -05:00
} ) ;
} catch ( auditErr ) {
2026-07-06 16:05:18 -05:00
log . error ( ` [audit-error] Failed to log rollback failure to audit log: ${ auditErr . message } ` ) ;
2026-05-10 10:44:39 -05:00
}
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 ( ) ;
2026-07-06 16:05:18 -05:00
log . info ( ` [cleanup] Purged ${ result . changes } expired sessions ` ) ;
2026-05-09 20:19:46 -05:00
return result ;
}
2026-07-06 14:23:53 -05:00
module .exports = {
getDb ,
getSetting ,
setSetting ,
closeDb ,
getDbPath ,
ensureUserDefaultCategories ,
cleanupExpiredSessions ,
rollbackMigration ,
} ;