feat(db): миграция схемы v1→v2 — command.deliveries, копия базы перед миграцией (#9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
234ea75d4f
commit
4f375fd39c
@ -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)")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user