Migrate transactions to SQLite (self-contained, on the volume)

- txns_db.py: SQLite store with the same list-of-dicts shape, ACID writes
  (no corruption / no lost-update race), indexes, and a fast unreviewed COUNT.
- app.py: get_transactions/save_transactions delegate to txns_db; one-time
  idempotent migration from transactions.json on startup (JSON kept as backup);
  nav badge uses count_unreviewed() instead of parsing the whole store (#33).
- Verified against the live 265-row dataset: counts, fields, splits, mutation.
- Other small JSON files intentionally stay JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 16:43:03 -05:00
parent 74d5e196c7
commit f3485fdc17
2 changed files with 171 additions and 5 deletions

17
app.py
View File

@@ -7,6 +7,7 @@ from datetime import datetime, date
import calendar as cal_module import calendar as cal_module
from storage import load_json, save_json from storage import load_json, save_json
from settings import load_settings, save_settings, SECRET_KEYS from settings import load_settings, save_settings, SECRET_KEYS
import txns_db
from categories import CATEGORIES, CATEGORY_COLORS from categories import CATEGORIES, CATEGORY_COLORS
from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num
from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed, from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed,
@@ -163,9 +164,8 @@ def compute_tax_estimate(gross_income, rates, se_income=None):
@app.context_processor @app.context_processor
def inject_unreviewed_count(): def inject_unreviewed_count():
transactions = get_transactions() # Fast COUNT query instead of parsing the whole transaction store per request.
count = sum(1 for t in transactions if not t.get("reviewed")) return dict(unreviewed_count=txns_db.count_unreviewed())
return dict(unreviewed_count=count)
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
TRANSACTIONS_FILE = os.path.join(DATA_DIR, "transactions.json") TRANSACTIONS_FILE = os.path.join(DATA_DIR, "transactions.json")
@@ -173,6 +173,13 @@ RULES_FILE = os.path.join(DATA_DIR, "rules.json")
BUDGET_FILE = os.path.join(DATA_DIR, "budget.json") BUDGET_FILE = os.path.join(DATA_DIR, "budget.json")
NET_WORTH_FILE = os.path.join(DATA_DIR, "net_worth_accounts.json") NET_WORTH_FILE = os.path.join(DATA_DIR, "net_worth_accounts.json")
# Transactions live in SQLite (self-contained, on the volume). On first run this
# imports any existing transactions.json; it's a no-op once the DB has rows.
txns_db.init_db()
_migrated = txns_db.migrate_from_json(TRANSACTIONS_FILE)
if _migrated:
print(f"[startup] migrated {_migrated} transactions from JSON -> SQLite")
def load_nw_accounts(): def load_nw_accounts():
return load_json(NET_WORTH_FILE, []) return load_json(NET_WORTH_FILE, [])
@@ -183,11 +190,11 @@ def save_nw_accounts(accounts):
def get_transactions(): def get_transactions():
return load_json(TRANSACTIONS_FILE, []) return txns_db.get_transactions()
def save_transactions(transactions): def save_transactions(transactions):
save_json(TRANSACTIONS_FILE, transactions) txns_db.save_transactions(transactions)
def get_rules(): def get_rules():

159
txns_db.py Normal file
View File

@@ -0,0 +1,159 @@
"""SQLite-backed storage for transactions (the large, query-heavy table).
Drop-in replacement for the old JSON list: get_transactions() returns the same
list-of-dicts shape (including a "splits" list per row) and save_transactions()
takes that same list. This keeps app.py's existing logic unchanged while giving
us ACID writes (no corruption / no lost-update race) and indexed queries.
The DB lives on the data volume (DATA_DIR/transactions.db) so it's self-
contained and captured by the existing tar.gz backup. WAL is checkpointed after
every write so the .db file stays authoritative for the read-only backup.
"""
import os
import sqlite3
from storage import DATA_DIR, load_json
DB_FILE = os.path.join(DATA_DIR, "transactions.db")
# Transaction dict keys persisted as columns (everything except the internal seq).
COLUMNS = ["id", "date", "description", "amount", "category", "subcategory",
"account", "notes", "reviewed", "ignored", "car_id", "txn_type"]
SCHEMA = """
CREATE TABLE IF NOT EXISTS transactions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT UNIQUE NOT NULL,
date TEXT,
description TEXT,
amount REAL,
category TEXT,
subcategory TEXT,
account TEXT,
notes TEXT,
reviewed INTEGER NOT NULL DEFAULT 0,
ignored INTEGER NOT NULL DEFAULT 0,
car_id TEXT,
txn_type TEXT
);
CREATE TABLE IF NOT EXISTS transaction_splits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txn_id TEXT NOT NULL,
category TEXT,
subcategory TEXT,
amount REAL,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_txn_reviewed ON transactions(reviewed);
CREATE INDEX IF NOT EXISTS idx_txn_date ON transactions(date);
CREATE INDEX IF NOT EXISTS idx_txn_cat ON transactions(reviewed, ignored, category);
CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(txn_id);
"""
def _connect():
conn = sqlite3.connect(DB_FILE, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
conn = _connect()
try:
conn.executescript(SCHEMA)
conn.commit()
finally:
conn.close()
def get_transactions():
"""Return all transactions as a list of dicts (import order), each with a
'splits' list — identical shape to the old JSON storage."""
conn = _connect()
try:
rows = conn.execute(
"SELECT id, date, description, amount, category, subcategory, account, "
"notes, reviewed, ignored, car_id, txn_type FROM transactions ORDER BY seq"
).fetchall()
srows = conn.execute(
"SELECT txn_id, category, subcategory, amount, note "
"FROM transaction_splits ORDER BY id"
).fetchall()
finally:
conn.close()
splits_by_id = {}
for s in srows:
splits_by_id.setdefault(s["txn_id"], []).append({
"category": s["category"], "subcategory": s["subcategory"],
"amount": s["amount"], "note": s["note"],
})
out = []
for r in rows:
t = dict(r)
t["reviewed"] = bool(r["reviewed"])
t["ignored"] = bool(r["ignored"])
t["splits"] = splits_by_id.get(r["id"], [])
out.append(t)
return out
def save_transactions(transactions):
"""Persist the full transaction list atomically (replace-all in one txn)."""
conn = _connect()
try:
conn.execute("DELETE FROM transaction_splits")
conn.execute("DELETE FROM transactions")
for t in transactions:
conn.execute(
"INSERT OR IGNORE INTO transactions "
"(id, date, description, amount, category, subcategory, account, "
"notes, reviewed, ignored, car_id, txn_type) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(t.get("id"), t.get("date"), t.get("description"),
float(t.get("amount") or 0), t.get("category"), t.get("subcategory"),
t.get("account"), t.get("notes"),
1 if t.get("reviewed") else 0, 1 if t.get("ignored") else 0,
t.get("car_id"), t.get("txn_type")),
)
for s in (t.get("splits") or []):
conn.execute(
"INSERT INTO transaction_splits (txn_id, category, subcategory, amount, note) "
"VALUES (?,?,?,?,?)",
(t.get("id"), s.get("category"), s.get("subcategory", ""),
float(s.get("amount") or 0), s.get("note", "")),
)
conn.commit()
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
conn.close()
def count_unreviewed():
"""Fast count for the per-request nav badge (was a full JSON parse)."""
conn = _connect()
try:
return conn.execute(
"SELECT COUNT(*) FROM transactions WHERE reviewed = 0"
).fetchone()[0]
finally:
conn.close()
def migrate_from_json(json_path):
"""One-time import of an existing transactions.json into SQLite.
No-op once the DB has any rows, so it's safe to call on every startup."""
conn = _connect()
try:
existing = conn.execute("SELECT COUNT(*) FROM transactions").fetchone()[0]
finally:
conn.close()
if existing > 0:
return 0
data = load_json(json_path, [])
if not data:
return 0
save_transactions(data)
return len(data)