feat(db): миграция схемы v1→v2 — command.deliveries, копия базы перед миграцией (#9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 22:40:59 +04:00
parent 234ea75d4f
commit 4f375fd39c
2 changed files with 73 additions and 6 deletions

View File

@ -6,7 +6,9 @@ from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
SCHEMA_VERSION = 1 from . import backup
SCHEMA_VERSION = 2
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS setting ( CREATE TABLE IF NOT EXISTS setting (
@ -67,6 +69,7 @@ CREATE TABLE IF NOT EXISTS command (
params TEXT NOT NULL, params TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued', status TEXT NOT NULL DEFAULT 'queued',
claimed_at TEXT, claimed_at TEXT,
deliveries INTEGER NOT NULL DEFAULT 0,
finished_at TEXT, finished_at TEXT,
result TEXT, result TEXT,
error 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: def now() -> str:
"""Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе.""" """Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе."""
@ -115,15 +124,32 @@ def connect(path) -> sqlite3.Connection:
if not is_memory: if not is_memory:
conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
migrate(conn) migrate(conn, None if is_memory else path)
return conn return conn
def migrate(conn: sqlite3.Connection) -> None: def _schema_version(conn) -> int | None:
conn.executescript(SCHEMA)
row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone() 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),)) 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)") conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")

View File

@ -1,3 +1,5 @@
import sqlite3
from jira_hub import db from jira_hub import db
@ -5,8 +7,47 @@ def test_connect_creates_schema():
conn = db.connect(":memory:") conn = db.connect(":memory:")
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} 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 {"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 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): def test_connect_is_idempotent(tmp_path):