From 67517926aa65c5986aebdffdf2c4466e4d5a5f8f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:32:32 -0400 Subject: [PATCH] feat: sqlite migration runner with meta version table --- chat/db/__init__.py | 0 chat/db/connection.py | 17 +++++++++++++++++ chat/db/migrate.py | 26 ++++++++++++++++++++++++++ chat/db/migrations/0001_init_meta.sql | 2 ++ tests/test_migrate.py | 22 ++++++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 chat/db/__init__.py create mode 100644 chat/db/connection.py create mode 100644 chat/db/migrate.py create mode 100644 chat/db/migrations/0001_init_meta.sql create mode 100644 tests/test_migrate.py diff --git a/chat/db/__init__.py b/chat/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/db/connection.py b/chat/db/connection.py new file mode 100644 index 0000000..ad21a01 --- /dev/null +++ b/chat/db/connection.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import sqlite3 +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def open_db(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + yield conn + conn.commit() + finally: + conn.close() diff --git a/chat/db/migrate.py b/chat/db/migrate.py new file mode 100644 index 0000000..95d9ba3 --- /dev/null +++ b/chat/db/migrate.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from pathlib import Path + +from chat.db.connection import open_db + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + + +def apply_migrations(db_path: Path) -> None: + with open_db(db_path) as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)" + ) + cur = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'") + row = cur.fetchone() + current = int(row[0]) if row else 0 + for path in sorted(MIGRATIONS_DIR.glob("*.sql")): + version = int(path.stem.split("_", 1)[0]) + if version <= current: + continue + sql = path.read_text() + conn.executescript(sql) + conn.execute( + "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", + (str(version),), + ) diff --git a/chat/db/migrations/0001_init_meta.sql b/chat/db/migrations/0001_init_meta.sql new file mode 100644 index 0000000..c0020e4 --- /dev/null +++ b/chat/db/migrations/0001_init_meta.sql @@ -0,0 +1,2 @@ +-- meta table is created by the migrate runner; this migration is a marker. +SELECT 1; diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..eaafcd3 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,22 @@ +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations + + +def test_apply_migrations_creates_meta_table(tmp_path): + db = tmp_path / "test.db" + apply_migrations(db) + with open_db(db) as conn: + row = conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + assert row is not None + assert int(row[0]) >= 1 + + +def test_apply_migrations_idempotent(tmp_path): + db = tmp_path / "test.db" + apply_migrations(db) + apply_migrations(db) # second call must be a no-op + with open_db(db) as conn: + count = conn.execute("SELECT COUNT(*) FROM meta").fetchone()[0] + assert count == 1