fix: preserve schema 15 compatibility for v0.1.8
This commit is contained in:
@@ -1,61 +1,10 @@
|
||||
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})")}
|
||||
|
||||
@@ -67,158 +16,8 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str
|
||||
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,
|
||||
@@ -284,7 +83,22 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
_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 (
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
valuation_amount INTEGER,
|
||||
valuation_date TEXT NOT NULL DEFAULT '',
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
basis_date TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
@@ -296,15 +110,16 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
valuation_source TEXT NOT NULL DEFAULT '',
|
||||
source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
snapshot_run_id 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 ''",
|
||||
_add_missing_columns(conn, "asset_snapshots", {
|
||||
"snapshot_run_id": "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 ''",
|
||||
@@ -314,13 +129,48 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
"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 INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
|
||||
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
||||
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
)""")
|
||||
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"})
|
||||
_add_missing_columns(conn, "asset_snapshot_runs", {
|
||||
"settlement_month": "TEXT",
|
||||
"currency": "TEXT",
|
||||
"income_total": "INTEGER",
|
||||
"expense_total": "INTEGER",
|
||||
"net_cashflow": "INTEGER",
|
||||
"total_assets": "INTEGER",
|
||||
"total_liabilities": "INTEGER",
|
||||
"net_worth": "INTEGER",
|
||||
"cashflow_policy_json": "TEXT",
|
||||
"aggregation_version": "TEXT",
|
||||
"snapshot_schema_version": "INTEGER",
|
||||
"source_snapshot_json": "TEXT",
|
||||
})
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(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 (settlement_run_id, cashflow_kind, category, subcategory, currency)
|
||||
)""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_type TEXT NOT NULL,
|
||||
@@ -355,58 +205,125 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
)""")
|
||||
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 TABLE IF NOT EXISTS account_valuations (
|
||||
id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance',
|
||||
source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '',
|
||||
verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL,
|
||||
memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '',
|
||||
as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER,
|
||||
reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER,
|
||||
return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER,
|
||||
representative_liability_definition TEXT
|
||||
)""")
|
||||
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("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL")
|
||||
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
|
||||
|
||||
|
||||
|
||||
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
|
||||
if statement.lstrip().upper().startswith("CREATE INDEX") or statement.lstrip().upper().startswith("CREATE UNIQUE INDEX"):
|
||||
return
|
||||
conn.execute(statement)
|
||||
|
||||
|
||||
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 _upgrade_cashflow_storage(conn):
|
||||
"""Preserve run identity: corrections cannot be merged into a single month.
|
||||
|
||||
Each frozen group belongs to exactly one legacy run or canonical month.
|
||||
No inferred dates or fabricated approvals are needed to retain legacy rows.
|
||||
"""
|
||||
columns = _existing_columns(conn, "settlement_cashflow_snapshots")
|
||||
if {"settlement_run_id", "monthly_settlement_id"} <= columns:
|
||||
return
|
||||
objects = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE tbl_name='settlement_cashflow_snapshots' AND type IN ('index','trigger') AND sql IS NOT NULL"
|
||||
).fetchall()
|
||||
if columns:
|
||||
conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO cashflow_upgrade_source")
|
||||
start = SCHEMA_SQL.index("CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots")
|
||||
end = SCHEMA_SQL.index(";", start) + 1
|
||||
conn.execute(SCHEMA_SQL[start:end])
|
||||
if columns:
|
||||
names = ",".join(sorted(columns))
|
||||
conn.execute(f"INSERT INTO settlement_cashflow_snapshots ({names}) SELECT {names} FROM cashflow_upgrade_source")
|
||||
conn.execute("DROP TABLE cashflow_upgrade_source")
|
||||
for row in objects:
|
||||
conn.execute(row["sql"])
|
||||
|
||||
|
||||
def _normalize_monthly_settlement_schema_15(conn: sqlite3.Connection) -> None:
|
||||
"""Remove the development-only schema-16 run pointer from schema 15."""
|
||||
if "latest_run_id" in _existing_columns(conn, "monthly_settlement"):
|
||||
conn.execute("ALTER TABLE monthly_settlement DROP COLUMN latest_run_id")
|
||||
|
||||
|
||||
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:
|
||||
if stored not in (None, "11", "12", "13", "14", "15"):
|
||||
if stored != "16" or not _compatible_known_v16(conn):
|
||||
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.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
# SQLite DDL and all row copies participate in this transaction.
|
||||
_execute_script_transactional(conn, SCHEMA_SQL)
|
||||
_migrate_cashflow_snapshots_to_monthly(conn)
|
||||
_upgrade_cashflow_storage(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,))
|
||||
_add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"})
|
||||
_normalize_monthly_settlement_schema_15(conn)
|
||||
# Re-run indexes only after all older-version columns have been added.
|
||||
for statement in SCHEMA_SQL.split(";"):
|
||||
if statement.strip().upper().startswith(("CREATE INDEX", "CREATE UNIQUE INDEX")):
|
||||
conn.execute(statement)
|
||||
if conn.execute("PRAGMA foreign_key_check").fetchone():
|
||||
raise sqlite3.IntegrityError("migration foreign key check failed")
|
||||
conn.commit()
|
||||
return str(path)
|
||||
|
||||
Reference in New Issue
Block a user