from __future__ import annotations import sqlite3 import json from .connection import connect, resolve_db_path from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS def _execute_script_transactional(conn: sqlite3.Connection, script: str) -> None: """Execute a schema script without executescript's implicit COMMIT.""" statement = "" for line in script.splitlines(): statement += line + "\n" if sqlite3.complete_statement(statement): _execute_one_schema_statement(conn, statement) statement = "" if statement.strip(): _execute_one_schema_statement(conn, statement) def _execute_one_schema_statement(conn: sqlite3.Connection, statement: str) -> None: if statement.lstrip().upper().startswith("PRAGMA JOURNAL_MODE=WAL"): return try: conn.execute(statement) except sqlite3.OperationalError as exc: if "no such column" not in str(exc): raise def _stored_version(conn: sqlite3.Connection) -> str | None: try: row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() except sqlite3.OperationalError as exc: if "no such table" in str(exc): return None raise return row["value"] if row else None def _compatible_known_v16(conn: sqlite3.Connection) -> bool: """Allow only the already-approved v16 development shape to normalize to v15.""" required = { "accounts": {"id", "name", "type", "liquidity_bucket"}, "transactions": {"id", "transaction_date", "amount", "raw_payload"}, "account_snapshot": {"id", "monthly_settlement_id", "amount", "verification_status"}, "monthly_settlement": {"id", "month", "status"}, "settlement_cashflow_snapshots": {"id", "monthly_settlement_id", "amount"}, } for table, columns in required.items(): if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone(): return False actual = _existing_columns(conn, table) if not columns <= actual: return False return not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('asset_snapshot_runs','manual_assets','asset_snapshots','account_valuations')").fetchone() def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]: return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: existing = _existing_columns(conn, table) for name, definition in columns.items(): if name not in existing: conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") def _drop_month_settlement_latest_run_id(conn: sqlite3.Connection) -> None: """Rebuild monthly_settlement so SQLite removes its FK-bearing column safely.""" if "latest_run_id" not in _existing_columns(conn, "monthly_settlement"): return # The canonical child table references this parent; SQLite otherwise # rejects the deliberate parent-table rebuild even though child rows stay. foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0] conn.execute("PRAGMA defer_foreign_keys=ON") conn.execute("PRAGMA foreign_keys=OFF") try: _drop_month_settlement_latest_run_id_impl(conn) finally: conn.execute(f"PRAGMA foreign_keys={'ON' if foreign_keys else 'OFF'}") def _drop_month_settlement_latest_run_id_impl(conn: sqlite3.Connection) -> None: conn.execute("""CREATE TABLE month_settlement__v16 ( id TEXT PRIMARY KEY, month TEXT NOT NULL UNIQUE CHECK ( length(month) = 7 AND substr(month, 5, 1) = '-' AND CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12 ), basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date), status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')), version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0), memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_by TEXT NOT NULL, updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT, review_fingerprint TEXT, approved_by TEXT, approved_at TEXT, approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)), approved_fingerprint TEXT, approved_cashflow_fingerprint TEXT, approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}', closed_by TEXT, closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT, CHECK (status NOT IN ('approved', 'closed') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL AND approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL)), CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)), CHECK (status <> 'cancelled' OR (cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL)) )""") columns = [row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] != "latest_run_id"] names = ", ".join(columns) conn.execute(f"INSERT INTO month_settlement__v16 ({names}) SELECT {names} FROM monthly_settlement") conn.execute("PRAGMA defer_foreign_keys=ON") conn.execute("PRAGMA foreign_keys=OFF") try: conn.execute("DROP TABLE monthly_settlement") conn.execute("ALTER TABLE month_settlement__v16 RENAME TO monthly_settlement") finally: conn.execute("PRAGMA foreign_keys=ON") conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)") def _migrate_cashflow_snapshots_to_monthly(conn: sqlite3.Connection) -> None: """Replace the retired run FK only when every legacy row maps uniquely.""" parent = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() child = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='settlement_cashflow_snapshots'").fetchone() if not child or "monthly_settlement_id" in (child["sql"] or ""): return if not parent: if conn.execute("SELECT 1 FROM settlement_cashflow_snapshots LIMIT 1").fetchone() is None: conn.execute("DROP TABLE settlement_cashflow_snapshots") conn.execute("""CREATE TABLE settlement_cashflow_snapshots ( id TEXT PRIMARY KEY, monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT, cashflow_kind TEXT NOT NULL, category TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '', amount INTEGER NOT NULL, transaction_count INTEGER NOT NULL, currency TEXT NOT NULL, UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency) )""") return raise sqlite3.DatabaseError("cannot migrate settlement cashflow snapshots: legacy run parent is missing") run_columns = _existing_columns(conn, "asset_snapshot_runs") period_column = next((c for c in ("settlement_month", "month", "period", "basis_date") if c in run_columns), None) if period_column is None: raise sqlite3.DatabaseError("cannot migrate settlement cashflow snapshots: legacy run has no month metadata") rows = conn.execute(f"SELECT id, {period_column} AS period FROM asset_snapshot_runs").fetchall() mapping = {} for row in rows: period = row["period"] month = period[:7] if period and len(period) >= 7 else period matches = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (month,)).fetchall() if len(matches) != 1: if conn.execute("SELECT 1 FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (row["id"],)).fetchone(): raise sqlite3.DatabaseError(f"ambiguous legacy settlement run mapping: {row['id']}") else: mapping[row["id"]] = matches[0]["id"] conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO settlement_cashflow_snapshots__legacy") conn.execute("""CREATE TABLE settlement_cashflow_snapshots ( id TEXT PRIMARY KEY, monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT, cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')), category TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '', amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'), transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0), currency TEXT NOT NULL, UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency) )""") for row in conn.execute("SELECT * FROM settlement_cashflow_snapshots__legacy"): target = mapping.get(row["settlement_run_id"]) if target is None: raise sqlite3.DatabaseError(f"cannot migrate legacy settlement run without unique monthly mapping: {row['settlement_run_id']}") conn.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,?,?,?,?,?,?)", (row["id"], target, row["cashflow_kind"], row["category"], row["subcategory"], row["amount"], row["transaction_count"], row["currency"])) conn.execute("DROP TABLE settlement_cashflow_snapshots__legacy") conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)") def _drop_legacy_snapshot_storage(conn: sqlite3.Connection) -> None: _migrate_cashflow_snapshots_to_monthly(conn) conn.execute("DROP TABLE IF EXISTS asset_snapshot_runs") def _rename_legacy_month_settlement(conn: sqlite3.Connection) -> None: """Rename the legacy parent before fresh schema creation updates child FKs.""" old = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='month_settlement'" ).fetchone() new = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='monthly_settlement'" ).fetchone() if old and new: raise sqlite3.DatabaseError("both month_settlement and monthly_settlement exist") if old: conn.execute("ALTER TABLE month_settlement RENAME TO monthly_settlement") def _rename_legacy_settlement_column(conn: sqlite3.Connection) -> None: """Rename the child FK column without rebuilding rows or changing schema version.""" columns = _existing_columns(conn, "account_snapshot") if "month_settlement_id" in columns and "monthly_settlement_id" in columns: raise sqlite3.DatabaseError("both settlement FK columns exist") if "month_settlement_id" in columns: conn.execute( "ALTER TABLE account_snapshot RENAME COLUMN month_settlement_id TO monthly_settlement_id" ) def _rename_legacy_snapshot_table(conn: sqlite3.Connection) -> None: """Rename the historical table in place, preserving rows and objects.""" old = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshots'" ).fetchone() new = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='account_snapshot'" ).fetchone() if old and new: raise sqlite3.DatabaseError("both asset_snapshots and account_snapshot exist") if old: conn.execute("ALTER TABLE asset_snapshots RENAME TO account_snapshot") def _drop_account_valuations(conn: sqlite3.Connection) -> None: """Remove the deprecated valuation table without touching other tables.""" conn.execute("DROP TABLE IF EXISTS account_valuations") def migrate_db(conn: sqlite3.Connection) -> None: """Apply additive, idempotent migrations only.""" _drop_account_valuations(conn) conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts ( id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '', create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '', final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '', cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '', approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0, approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '', approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '', approval_flags TEXT NOT NULL DEFAULT '{}' )""") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'") conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)") _add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''", "cashflow_policy_json": "TEXT NOT NULL DEFAULT '{}'", "aggregation_version": "TEXT NOT NULL DEFAULT 'comprehensive-v1'"}) conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items ( id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id), source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '', selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '', included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '', selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}', UNIQUE(draft_id,source_type,source_id) )""") _add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS) _add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS) conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)") conn.execute("""CREATE TABLE IF NOT EXISTS category_rules ( id TEXT PRIMARY KEY, priority INTEGER NOT NULL DEFAULT 100, match_field TEXT NOT NULL, match_type TEXT NOT NULL DEFAULT 'contains', pattern TEXT NOT NULL, amount_min INTEGER, amount_max INTEGER, day_of_month INTEGER, transaction_type TEXT NOT NULL DEFAULT '', category TEXT NOT NULL, subcategory TEXT NOT NULL DEFAULT '', person TEXT NOT NULL DEFAULT '', is_fixed INTEGER, confidence REAL NOT NULL DEFAULT 1.0, active INTEGER NOT NULL DEFAULT 1, memo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") _add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"}) conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)") conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories ( id TEXT PRIMARY KEY, category TEXT NOT NULL, subcategory TEXT NOT NULL DEFAULT '', cashflow_kind TEXT NOT NULL DEFAULT 'expense', active INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100, memo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") _add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"}) conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)") conn.execute("""CREATE TABLE IF NOT EXISTS account_snapshot ( id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL, source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER, currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other', valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""") _add_missing_columns(conn, "account_snapshot", { "monthly_settlement_id": "TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT", "correction_of_run_id": "TEXT NOT NULL DEFAULT ''", "correction_reason": "TEXT NOT NULL DEFAULT ''", "is_correction": "INTEGER NOT NULL DEFAULT 0", "closed_at": "TEXT NOT NULL DEFAULT ''", "valuation_id": "TEXT NOT NULL DEFAULT ''", "selection_policy": "TEXT NOT NULL DEFAULT 'exact'", "verification_status": "TEXT NOT NULL DEFAULT 'unverified'", "evidence_kind": "TEXT NOT NULL DEFAULT 'applied'", "exclusion_reason": "TEXT NOT NULL DEFAULT ''", }) conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id)") if "monthly_settlement_id" in _existing_columns(conn, "account_snapshot"): conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_month_settlement ON account_snapshot(monthly_settlement_id, basis_date, created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)") conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL, target_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', decision_type TEXT NOT NULL DEFAULT 'unknown', reason_code TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', operator TEXT NOT NULL DEFAULT 'operator', decided_at TEXT NOT NULL DEFAULT '', review_after TEXT NOT NULL DEFAULT '', target_payload TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at)") conn.execute("""CREATE TABLE IF NOT EXISTS reconciliation_sessions ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL, basis_date TEXT NOT NULL, expected_balance INTEGER, actual_balance INTEGER, difference INTEGER, status TEXT NOT NULL DEFAULT 'open', closed_at TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', operator TEXT NOT NULL DEFAULT 'operator', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)") _drop_month_settlement_latest_run_id(conn) conn.execute("""CREATE TABLE IF NOT EXISTS monthly_settlement ( id TEXT PRIMARY KEY, month TEXT NOT NULL UNIQUE CHECK ( length(month) = 7 AND substr(month, 5, 1) = '-' AND CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12 ), basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date), status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')), version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0), memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_by TEXT NOT NULL, updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT, review_fingerprint TEXT, approved_by TEXT, approved_at TEXT, approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)), approved_fingerprint TEXT, approved_cashflow_fingerprint TEXT, approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}', closed_by TEXT, closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT, CHECK (status NOT IN ('approved', 'closed') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL AND approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL)), CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)), CHECK (status <> 'cancelled' OR (cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL)) )""") conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)") _add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"}) _drop_legacy_snapshot_storage(conn) # schema version 16 retires manual_assets without creating replacement rows. conn.execute("DROP TABLE IF EXISTS manual_assets") conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) def init_db(db_path: str | None = None) -> str: path = resolve_db_path(db_path) with connect(str(path)) as conn: stored = _stored_version(conn) if stored not in (None, "14", "15"): if stored == "16" and _compatible_known_v16(conn): pass else: raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade") # Keep the entire migration atomic. In particular, executescript() commits # implicitly, which would leave half-renamed legacy tables after a failure. conn.execute("BEGIN") conn.execute("PRAGMA defer_foreign_keys=ON") conn.execute("PRAGMA foreign_keys=OFF") _rename_legacy_month_settlement(conn) _rename_legacy_snapshot_table(conn) _rename_legacy_settlement_column(conn) # The canonical schema creates monthly_settlement before legacy cashflow # rows are mapped from asset_snapshot_runs. _execute_script_transactional(conn, SCHEMA_SQL) _migrate_cashflow_snapshots_to_monthly(conn) migrate_db(conn) conn.execute("PRAGMA foreign_keys=ON") conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) conn.commit() return str(path)