fix(hub): транзакции в set_note и rename_note, сохранение заметки при равных ключах

rename_note: при old_key == new_key ранний выход (DELETE раньше стирал единственную заметку);
DELETE и UPDATE выполняются в транзакции.
set_note: чтение, слияние и запись в одной транзакции (соединение общее для потоков).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 21:57:25 +04:00
parent e981d62620
commit 3394ddcf4c
2 changed files with 37 additions and 10 deletions

View File

@ -95,20 +95,24 @@ def set_note(conn, key, fields: dict, updated_by: str) -> dict:
unknown = set(fields) - set(NOTE_FIELDS) unknown = set(fields) - set(NOTE_FIELDS)
if unknown: if unknown:
raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}") raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}")
cur = get_note(conn, key) or {"key": key} with db.transaction(conn): # чтение, слияние и запись — атомарно (соединение общее для потоков)
cur.update(fields) cur = get_note(conn, key) or {"key": key}
cur["updated_at"], cur["updated_by"] = db.now(), updated_by cur.update(fields)
cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"] cur["updated_at"], cur["updated_by"] = db.now(), updated_by
conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"]
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]), conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
[cur.get(c) for c in cols]) "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]),
[cur.get(c) for c in cols])
return get_note(conn, key) return get_note(conn, key)
def rename_note(conn, old_key, new_key) -> None: def rename_note(conn, old_key, new_key) -> None:
"""После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом, """После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом,
если вдруг была, уступает место).""" если вдруг была, уступает место)."""
if get_note(conn, old_key) is None: if old_key == new_key:
return return
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) with db.transaction(conn):
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) if get_note(conn, old_key) is None:
return
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,))
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))

View File

@ -69,3 +69,26 @@ def test_list_issues_filters():
assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"] assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"]
assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None
assert len(mirror.list_issues(conn, None, limit=1)) == 1 assert len(mirror.list_issues(conn, None, limit=1)) == 1
def test_rename_note_same_key_keeps_note():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1")], [])
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
mirror.rename_note(conn, "A-1", "A-1")
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
def test_rename_note_missing_is_noop():
conn = db.connect(":memory:")
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
assert mirror.get_note(conn, "new:404") is None
assert mirror.get_note(conn, "A-9") is None
def test_set_note_partial_keeps_other_fields():
conn = db.connect(":memory:")
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"