From 4f375fd39c0a68a6701be033198c1667930d7fd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:40:59 +0400 Subject: [PATCH] =?UTF-8?q?feat(db):=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20v1?= =?UTF-8?q?=E2=86=92v2=20=E2=80=94=20command.deliveries,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BF=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=B7=D1=8B=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/db.py | 36 +++++++++++++++++++++++++++++++----- tests/test_db.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 9b16192..01d0c06 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -6,7 +6,9 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -SCHEMA_VERSION = 1 +from . import backup + +SCHEMA_VERSION = 2 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -67,6 +69,7 @@ CREATE TABLE IF NOT EXISTS command ( params TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued', claimed_at TEXT, + deliveries INTEGER NOT NULL DEFAULT 0, finished_at TEXT, result TEXT, error TEXT, @@ -98,6 +101,12 @@ CREATE TABLE IF NOT EXISTS agent_status ( ); """ +# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5) +MIGRATIONS = { + 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", + "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача +} + def now() -> str: """Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе.""" @@ -115,15 +124,32 @@ def connect(path) -> sqlite3.Connection: if not is_memory: conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA foreign_keys = ON") - migrate(conn) + migrate(conn, None if is_memory else path) return conn -def migrate(conn: sqlite3.Connection) -> None: - conn.executescript(SCHEMA) +def _schema_version(conn) -> int | None: row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone() - if row is None: + return None if row is None else int(json.loads(row["value"])) + + +def migrate(conn: sqlite3.Connection, path=None) -> None: + """Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Перед миграцией файловой базы — + копия в <каталог базы>/backups (без ротации). Параллельный connect безопасен: версия перечитывается в транзакции.""" + conn.executescript(SCHEMA) + version = _schema_version(conn) + if version is None: conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) + elif version < SCHEMA_VERSION: + if path is not None: + backup.make_backup(conn, Path(path).parent / "backups", f"schema-v{version}", keep=0) + with transaction(conn): + current = _schema_version(conn) + while current < SCHEMA_VERSION: + for sql in MIGRATIONS[current]: + conn.execute(sql) + current += 1 + conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") diff --git a/tests/test_db.py b/tests/test_db.py index 0133f79..df32089 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,3 +1,5 @@ +import sqlite3 + from jira_hub import db @@ -5,8 +7,47 @@ def test_connect_creates_schema(): conn = db.connect(":memory:") names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "1" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} + assert "deliveries" in cols + + +def _v1_database(path): + """База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными.""" + conn = db.connect(path) + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'claude', '2026-10-13T00:00:00+00:00')") + for status in ("claimed", "done", "queued"): + conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)", + (status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None)) + conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + conn.execute("ALTER TABLE command DROP COLUMN deliveries") + conn.execute("UPDATE setting SET value = '1' WHERE key = 'schema_version'") + conn.close() + + +def test_migrates_v1_with_backup(tmp_path): + path = tmp_path / "hub.sqlite" + _v1_database(path) + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() + assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] + assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + copies = list((tmp_path / "backups").glob("*-schema-v1.sqlite")) + assert len(copies) == 1 + old = sqlite3.connect(copies[0]) # не db.connect — он бы мигрировал копию + assert "deliveries" not in {r[1] for r in old.execute("PRAGMA table_info(command)")} + assert old.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()[0] == "1" + old.close() + conn.close() + db.connect(path).close() # повторный connect не мигрирует и не копирует + assert len(list((tmp_path / "backups").glob("*.sqlite"))) == 1 + + +def test_new_database_makes_no_backup(tmp_path): + db.connect(tmp_path / "hub.sqlite").close() + assert not (tmp_path / "backups").exists() def test_connect_is_idempotent(tmp_path):