From 12d9d4c5a838ad35c003e50859effbd80c78f980 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 13:34:01 -0500 Subject: [PATCH] refactor(db): extract legacy-reconcile migrations + lock version-sync (IMP-CODE-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the migrations split: moved the ~830-line reconcileLegacyMigrations array into db/migrations/legacyReconcileMigrations.js via the same buildLegacyReconcileMigrations(deps) factory (same injected db + helpers; no inline requires in this one). database.js is now 1,297 lines — down from 4,174 at the start of IMP-CODE-02 (~69%). Added tests/migrationModules.test.js locking the invariants database.js depends on: both modules build, versioned versions are unique, and every legacy-reconcile version has a versioned counterpart (the drift the in-app assertion warns about). Verified: full suite 125 pass; fresh DB applies all 79 migrations (reconcile path included — a fresh schema.sql DB triggers it) and is idempotent; real prod DB copy (v1.06) migrates as a no-op with data intact and no version-sync drift. Co-Authored-By: Claude Opus 4.8 --- db/database.js | 846 +------------------- db/migrations/legacyReconcileMigrations.js | 853 +++++++++++++++++++++ tests/migrationModules.test.js | 35 + 3 files changed, 900 insertions(+), 834 deletions(-) create mode 100644 db/migrations/legacyReconcileMigrations.js create mode 100644 tests/migrationModules.test.js diff --git a/db/database.js b/db/database.js index 33499df..e339bdc 100644 --- a/db/database.js +++ b/db/database.js @@ -77,6 +77,8 @@ function isValidSqlDefinition(def) { const { SUBSCRIPTION_CATALOG_ROWS, SUBSCRIPTION_CATALOG_V2_ROWS } = require('./subscriptionCatalogSeed'); // The versioned migration list lives in its own module (a factory injected with db + helpers). const buildVersionedMigrations = require('./migrations/versionedMigrations'); +// The legacy-reconcile migration list (same factory pattern, legacy DBs only). +const buildLegacyReconcileMigrations = require('./migrations/legacyReconcileMigrations'); function runSubscriptionCatalogV2Migration(database) { // Category fixes for existing rows @@ -499,840 +501,16 @@ function handleLegacyDatabase() { 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'); - }, - 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', - 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; - }, - 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', - 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; - }, - 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', - 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)); - }, - 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', - 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'); - }, - 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', - 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'); - }, - 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', - 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; - }, - 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', - 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)); - }, - 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', - 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; - }, - 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', - 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; - }, - 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', - 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'); - }, - 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', - description: 'import_history: per-user audit log', - check: function() { - // Already handled in v0.15 - return true; - }, - run: function() { - // This was already handled in v0.15, but keeping for completeness - } - }, - { - 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'); - }, - 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)'); - } - }, - { - 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'); - }, - 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'); - } - } - }, - { - 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; - }, - 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)'); - } - }, - { - 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'); - } - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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, - user_agent TEXT, - browser TEXT, - os TEXT, - device_type TEXT, - device_fingerprint TEXT - ) - `); - console.log('[migration] user_login_history table created'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - }, - { - 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'); - } - } - }, - { - 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); - } - }, - { - 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); - `); - } - }, - { - 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); - `); - } - } - ]; + const migrations = buildLegacyReconcileMigrations({ + db, + isValidColumnName, + isValidSqlDefinition, + ensureTransactionFoundationSchema, + runSubscriptionCatalogMigration, + runSubscriptionCatalogV2Migration, + runAdvisoryFiltersMigration, + runMerchantStoreMatchMigration, + }); // Check for legacy notification columns const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); diff --git a/db/migrations/legacyReconcileMigrations.js b/db/migrations/legacyReconcileMigrations.js new file mode 100644 index 0000000..b79ad5a --- /dev/null +++ b/db/migrations/legacyReconcileMigrations.js @@ -0,0 +1,853 @@ +'use strict'; + +// The legacy-reconcile migration list, extracted from db/database.js. Same +// factory pattern as versionedMigrations: each check/run body closes over the +// live db + schema helpers, injected here. Runs only for legacy databases whose +// schema predates migration tracking (and for a freshly schema.sql'd DB, whose +// schema_migrations starts empty). Consumed by reconcileLegacyMigrations(). +module.exports = function buildLegacyReconcileMigrations(deps) { + const { + db, + isValidColumnName, + isValidSqlDefinition, + ensureTransactionFoundationSchema, + runSubscriptionCatalogMigration, + runSubscriptionCatalogV2Migration, + runAdvisoryFiltersMigration, + runMerchantStoreMatchMigration, + } = deps; + return [ + { + 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'); + }, + 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', + 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; + }, + 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', + 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; + }, + 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', + 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)); + }, + 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', + 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'); + }, + 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', + 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'); + }, + 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', + 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; + }, + 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', + 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)); + }, + 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', + 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; + }, + 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', + 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; + }, + 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', + 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'); + }, + 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', + description: 'import_history: per-user audit log', + check: function() { + // Already handled in v0.15 + return true; + }, + run: function() { + // This was already handled in v0.15, but keeping for completeness + } + }, + { + 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'); + }, + 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)'); + } + }, + { + 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'); + }, + 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'); + } + } + }, + { + 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; + }, + 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)'); + } + }, + { + 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'); + } + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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, + user_agent TEXT, + browser TEXT, + os TEXT, + device_type TEXT, + device_fingerprint TEXT + ) + `); + console.log('[migration] user_login_history table created'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + }, + { + 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'); + } + } + }, + { + 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); + } + }, + { + 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); + `); + } + }, + { + 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); + `); + } + } + ]; +}; diff --git a/tests/migrationModules.test.js b/tests/migrationModules.test.js new file mode 100644 index 0000000..918bbef --- /dev/null +++ b/tests/migrationModules.test.js @@ -0,0 +1,35 @@ +'use strict'; + +// IMP-CODE-02: the migration arrays were extracted from db/database.js into +// factory modules. These guard the invariants db/database.js relies on: +// - both modules load and build their arrays (run/check bodies aren't invoked +// at construction, so empty deps are fine); +// - the versioned list has no duplicate versions; +// - every legacy-reconcile version exists in the versioned list (reconcile only +// marks known migrations as applied — a reconcile version with no versioned +// counterpart is the drift the in-app assertion warns about). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const buildVersioned = require('../db/migrations/versionedMigrations'); +const buildReconcile = require('../db/migrations/legacyReconcileMigrations'); + +test('both migration modules build their arrays with no deps needed at construction', () => { + const versioned = buildVersioned({}); + const reconcile = buildReconcile({}); + assert.ok(Array.isArray(versioned) && versioned.length > 0, 'versioned array non-empty'); + assert.ok(Array.isArray(reconcile) && reconcile.length > 0, 'reconcile array non-empty'); + for (const m of versioned) assert.equal(typeof m.run, 'function', `${m.version} has a run()`); + for (const m of reconcile) assert.equal(typeof m.check, 'function', `${m.version} has a check()`); +}); + +test('versioned migration versions are unique', () => { + const versions = buildVersioned({}).map(m => m.version); + assert.equal(new Set(versions).size, versions.length, 'no duplicate versions'); +}); + +test('every legacy-reconcile version exists in the versioned list (no drift)', () => { + const versionedSet = new Set(buildVersioned({}).map(m => m.version)); + const orphans = buildReconcile({}).map(m => m.version).filter(v => !versionedSet.has(v)); + assert.deepEqual(orphans, [], `reconcile versions with no versioned counterpart: ${orphans.join(', ')}`); +});