Two milestones: a note can carry its own check (317), and a rule keeps what it used to say (323) #135
@@ -0,0 +1,83 @@
|
||||
"""rules gain an edit history — rule_versions (milestone 323 step 1)
|
||||
|
||||
Revision ID: 0093
|
||||
Revises: 0092
|
||||
Create Date: 2026-08-29
|
||||
|
||||
The sibling `note_versions` has had for a long time. A note's every meaningful
|
||||
edit is snapshotted, and the design-system note calls that history "the
|
||||
changelog". A RULE — which binds behaviour on every session that loads it —
|
||||
had nothing: an edit destroyed what it used to say, with no record anywhere.
|
||||
|
||||
Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement
|
||||
had to be hand-copied into a task log to survive the edit (#3237), which is
|
||||
not a process, it is a person remembering. The more consequential record had
|
||||
the weaker protection.
|
||||
|
||||
Three things are deliberately NOT copied from note_versions, and each is a
|
||||
guard that exists there for a reason that does not hold here:
|
||||
|
||||
- **No pruning, and no MAX_VERSIONS.** That cap defends against note autosave
|
||||
filling every slot. Rules have no autosave; every edit is a deliberate
|
||||
update_rule. A rule is edited a handful of times in its life, and capping
|
||||
invites losing the one edit somebody needed.
|
||||
- **No pin columns.** `pin_kind`/`pin_label` exist so a note's version can
|
||||
survive that pruning. With nothing pruning, a pin protects a row that was
|
||||
never at risk.
|
||||
- **No minimum interval.** 300 seconds between snapshots is also an autosave
|
||||
defence; here it would only ever discard a second deliberate edit.
|
||||
|
||||
`user_id` is the ACTOR rather than the owner, and is SET NULL rather than
|
||||
CASCADE: deleting a user must not erase the history of the rules they edited.
|
||||
The edit still happened and the rule still binds because of it.
|
||||
|
||||
No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a
|
||||
migration cannot invent the text a rule used to have, and inventing "the
|
||||
current text, as of now" would be worse than an empty history, because it
|
||||
would look like a record of an edit that never occurred.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0093"
|
||||
down_revision = "0092"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_versions",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("rules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("statement", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("why", sa.Text(), nullable=True),
|
||||
sa.Column("how_to_apply", sa.Text(), nullable=True),
|
||||
sa.Column("when_to_apply", sa.Text(), nullable=True),
|
||||
sa.Column("tier", sa.Text(), nullable=True),
|
||||
sa.Column("verify_with", sa.Text(), nullable=True),
|
||||
sa.Column("expires_when", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
# The only query this table serves is "the history of THIS rule, newest
|
||||
# first" — unlike 0092's columns, which are read by an operator-initiated
|
||||
# sweep over the whole set. Every read here is keyed on rule_id, so the
|
||||
# index earns its write cost immediately rather than on a hunch.
|
||||
op.create_index("ix_rule_versions_rule_id", "rule_versions", ["rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_versions_rule_id", table_name="rule_versions")
|
||||
op.drop_table("rule_versions")
|
||||
@@ -33,6 +33,7 @@ from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
|
||||
from scribe.models.note_version import NoteVersion # noqa: E402, F401
|
||||
from scribe.models.rule_version import RuleVersion # noqa: E402, F401
|
||||
from scribe.models.note_supersession import NoteSupersession # noqa: E402, F401
|
||||
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
||||
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
|
||||
class RuleVersion(Base, CreatedAtMixin):
|
||||
"""One snapshot of a rule's text, taken before an edit overwrote it.
|
||||
|
||||
THE SIBLING NOTES ALREADY HAD. `note_versions` has existed for a long
|
||||
time, and the design-system note calls its history "the changelog". Rules
|
||||
— which BIND BEHAVIOUR on every session that loads them — had nothing, so
|
||||
an edit destroyed what the rule used to say. Rescoping rule 79 on
|
||||
2026-08-29 meant hand-copying the superseded statement into a task log to
|
||||
keep it (#3237). The more consequential record had the weaker protection.
|
||||
|
||||
`CreatedAtMixin`, not `TimestampMixin`: a version is an EVENT. It is
|
||||
written once and never updated, so an `updated_at` on it would be a column
|
||||
that can only ever lie.
|
||||
|
||||
WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion (milestone 323):
|
||||
|
||||
- `user_id` is the ACTOR — who made the edit — where NoteVersion's is the
|
||||
owner, because `update_note` passes an owner-scoped id. For an audit
|
||||
trail over a binding instruction, "who changed this" is the question
|
||||
being asked, and a rule is editable by anyone with rulebook access.
|
||||
- No `pin_kind` / `pin_label`. Those exist so a note's version can survive
|
||||
autosave pruning. Nothing prunes here, so a pin would protect a row that
|
||||
was never at risk.
|
||||
- TEXT ONLY. A rule's Systems and its typed relations are edges with their
|
||||
own lifecycle; folding them in would make one word, "version", mean two
|
||||
different things — the rule's wording, and the rule's place in the
|
||||
graph. `verified_at` is likewise absent: it is a stamp about a check,
|
||||
not a property of the text, and step 2's snapshot is taken before
|
||||
update_rule clears it precisely so the history shows the check that was
|
||||
in force when this wording was written.
|
||||
"""
|
||||
|
||||
__tablename__ = "rule_versions"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
rule_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# The actor. SET NULL rather than CASCADE: deleting a user must not erase
|
||||
# the history of the rules they edited — the edit still happened, and the
|
||||
# rule is still binding because of it. NoteVersion cascades because a
|
||||
# note's versions belong to its owner; a rule's belong to the rule.
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
statement: Mapped[str] = mapped_column(Text, default="")
|
||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tier: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def to_dict(self, include_text: bool = True) -> dict:
|
||||
"""The row. `include_text=False` gives the listing form.
|
||||
|
||||
A rule's `statement` and `why` run to thousands of characters — rule
|
||||
149's `why` alone is longer than most notes — so a history LIST that
|
||||
carried every field would be unreadable and expensive. The listing
|
||||
answers "when, and by whom"; opening one answers "and what did it
|
||||
say". Same split NoteVersion makes with `include_body`.
|
||||
"""
|
||||
out: dict = {
|
||||
"id": self.id,
|
||||
"rule_id": self.rule_id,
|
||||
"user_id": self.user_id,
|
||||
"title": self.title,
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
if include_text:
|
||||
out.update({
|
||||
"statement": self.statement,
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"when_to_apply": self.when_to_apply or "",
|
||||
"tier": self.tier or "",
|
||||
"verify_with": self.verify_with or "",
|
||||
"expires_when": self.expires_when or "",
|
||||
})
|
||||
return out
|
||||
@@ -9,6 +9,7 @@ from scribe.models.note import Note
|
||||
from scribe.models.note_draft import NoteDraft
|
||||
from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
@@ -60,8 +61,9 @@ logger = logging.getLogger(__name__)
|
||||
# note, every issue and spike into `work`, and every plan reduced to a title.
|
||||
# _COLUMN_EXCLUSIONS and its guard landed with it, so the next such column
|
||||
# fails the build instead.
|
||||
# v13 (2026-08) added rule_versions — a rule's edit history (milestone 323).
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 12
|
||||
BACKUP_VERSION = 13
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
@@ -87,6 +89,9 @@ _BACKED_UP = [
|
||||
"canonical_systems",
|
||||
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
|
||||
"rule_systems", "rule_relations",
|
||||
# v13 (2026-08): a rule's edit history (milestone 323). note_versions has
|
||||
# always travelled; its sibling has no excuse not to.
|
||||
"rule_versions",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -176,6 +181,10 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||
"repo_bindings": {"id", "created_at", "updated_at"},
|
||||
# Everything travels. A version row IS the audit trail, so a column left
|
||||
# behind is a fact about a binding instruction that no longer exists
|
||||
# anywhere.
|
||||
"rule_versions": set(),
|
||||
# Serialised via the model's own to_dict(), so a column reaches the backup
|
||||
# the moment it reaches that method — and the guard still catches one that
|
||||
# reaches neither.
|
||||
@@ -472,6 +481,23 @@ def _note_version_rows(rows) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _rule_version_rows(rows) -> list[dict]:
|
||||
"""A rule's edit history. Sibling of _note_version_rows, and it travels for
|
||||
the same reason: a version is the only record of what a binding
|
||||
instruction used to say, and nothing can recompute it."""
|
||||
return [
|
||||
{
|
||||
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
||||
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
||||
"how_to_apply": rv.how_to_apply, "when_to_apply": rv.when_to_apply,
|
||||
"tier": rv.tier, "verify_with": rv.verify_with,
|
||||
"expires_when": rv.expires_when,
|
||||
"created_at": rv.created_at.isoformat(),
|
||||
}
|
||||
for rv in rows
|
||||
]
|
||||
|
||||
|
||||
def _setting_rows(rows) -> list[dict]:
|
||||
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
|
||||
|
||||
@@ -553,6 +579,9 @@ async def export_full_backup() -> dict:
|
||||
note_versions = (await session.execute(
|
||||
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
|
||||
)).scalars().all()
|
||||
rule_versions = (await session.execute(
|
||||
select(RuleVersion).order_by(RuleVersion.rule_id, RuleVersion.id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
systems = (await session.execute(select(System))).scalars().all()
|
||||
canonical_systems = (await session.execute(
|
||||
@@ -617,6 +646,7 @@ async def export_full_backup() -> dict:
|
||||
"task_logs": _task_log_rows(task_logs),
|
||||
"note_drafts": _note_draft_rows(note_drafts),
|
||||
"note_versions": _note_version_rows(note_versions),
|
||||
"rule_versions": _rule_version_rows(rule_versions),
|
||||
"settings": _setting_rows(settings),
|
||||
"rulebooks": _rulebook_rows(rulebooks),
|
||||
"rulebook_topics": _topic_rows(topics),
|
||||
@@ -753,6 +783,14 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
|
||||
)).all() if _rule_ids else []
|
||||
# Scoped through the RULE, not the version's user_id. That column is
|
||||
# the ACTOR (milestone 323), so filtering on it would carry the
|
||||
# versions this user wrote on someone ELSE's rule and drop the ones
|
||||
# someone else wrote on theirs — the opposite of a per-user export.
|
||||
rule_versions = (await session.execute(
|
||||
select(RuleVersion).where(RuleVersion.rule_id.in_(_rule_ids))
|
||||
.order_by(RuleVersion.rule_id, RuleVersion.id)
|
||||
)).scalars().all() if _rule_ids else []
|
||||
rule_relations = (await session.execute(
|
||||
select(RuleRelation).where(
|
||||
RuleRelation.from_rule_id.in_(_rule_ids),
|
||||
@@ -801,6 +839,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"task_logs": _task_log_rows(task_logs),
|
||||
"note_drafts": _note_draft_rows(note_drafts),
|
||||
"note_versions": _note_version_rows(note_versions),
|
||||
"rule_versions": _rule_version_rows(rule_versions),
|
||||
"settings": _setting_rows(settings),
|
||||
"rulebooks": _rulebook_rows(rulebooks),
|
||||
"rulebook_topics": _topic_rows(topics),
|
||||
@@ -958,7 +997,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
"code_shape_uses": 0, "canonical_systems": 0,
|
||||
"rule_systems": 0, "rule_relations": 0,
|
||||
"rule_systems": 0, "rule_relations": 0, "rule_versions": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -1327,6 +1366,32 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["rule_relations"] += 1
|
||||
|
||||
# A rule's edit history (milestone 323). Must come after the rules
|
||||
# themselves — rule_id_map is only populated above — and both ids are
|
||||
# ids in the SOURCE database, which is #3182's arose_from_id trap.
|
||||
for rv in data.get("rule_versions", []):
|
||||
mapped_rid = rule_id_map.get(rv.get("rule_id", 0))
|
||||
if mapped_rid is None:
|
||||
continue
|
||||
# Unlike NoteVersion, an unmappable user does NOT drop the row.
|
||||
# user_id is the ACTOR and is nullable by design: the column is
|
||||
# SET NULL precisely so history outlives the account that wrote
|
||||
# it. Skipping here would delete the record the FK preserves.
|
||||
session.add(RuleVersion(
|
||||
rule_id=mapped_rid,
|
||||
user_id=user_id_map.get(rv.get("user_id") or 0),
|
||||
title=rv.get("title", ""),
|
||||
statement=rv.get("statement", ""),
|
||||
why=rv.get("why"),
|
||||
how_to_apply=rv.get("how_to_apply"),
|
||||
when_to_apply=rv.get("when_to_apply"),
|
||||
tier=rv.get("tier"),
|
||||
verify_with=rv.get("verify_with"),
|
||||
expires_when=rv.get("expires_when"),
|
||||
created_at=_dt(rv.get("created_at")),
|
||||
))
|
||||
stats["rule_versions"] += 1
|
||||
|
||||
# 15. Systems
|
||||
for sy_data in data.get("systems", []):
|
||||
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Real-Postgres round trip for rule_versions (milestone 323 step 1).
|
||||
|
||||
Two things here cannot be shown with mocks, and both are the kind that fail
|
||||
QUIETLY — a restore reports success and hands back history that is wrong.
|
||||
|
||||
1. **`rule_id` is remapped, not copied.** It is an id in the SOURCE database.
|
||||
A restore that writes it straight through succeeds and attaches every
|
||||
snapshot to whatever rule happens to hold that number here — an edit
|
||||
history filed against the wrong binding instruction, which is worse than
|
||||
no history at all. This is #3182's `arose_from_id` trap on a new table.
|
||||
|
||||
2. **A null actor does not drop the row.** `user_id` is SET NULL precisely so
|
||||
history outlives the account that wrote it, so the restore deliberately
|
||||
diverges from its `NoteVersion` sibling, which skips a version it cannot
|
||||
map to a user. Nothing about the code says which of the two shapes is
|
||||
intended; without this test, "make it match the sibling" reads as a tidy-up
|
||||
and silently deletes the record the FK was chosen to preserve.
|
||||
|
||||
These drive the REAL `restore_full_backup`. A test that re-derived the remap
|
||||
would agree with whatever the product does, including nothing.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
|
||||
from scribe.models.user import User
|
||||
from scribe.services import backup
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER_USERNAME = "rule_version_roundtrip_owner"
|
||||
RESTORED_USERNAME = "rule_version_roundtrip_restored"
|
||||
|
||||
|
||||
async def _purge_books(username: str) -> None:
|
||||
"""user -> rulebook -> topic -> rule -> rule_version is ON DELETE CASCADE
|
||||
the whole way down, and no ORM relationships are configured, so dropping
|
||||
the books clears every row this file made under them."""
|
||||
async with async_session() as s:
|
||||
users = (await s.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalars().all()
|
||||
for user in users:
|
||||
books = (await s.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user.id)
|
||||
)).scalars().all()
|
||||
for book in books:
|
||||
await s.delete(book)
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _purge_restored() -> None:
|
||||
"""The restored user is minted by the restore itself, so it goes too."""
|
||||
await _purge_books(RESTORED_USERNAME)
|
||||
async with async_session() as s:
|
||||
for user in (await s.execute(
|
||||
select(User).where(User.username == RESTORED_USERNAME)
|
||||
)).scalars().all():
|
||||
await s.delete(user)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _no_leftovers():
|
||||
"""The usernames are fixed, so a previous failed run would leave rows that
|
||||
make the fixtures below pick the wrong user — or hit `.one()` with two."""
|
||||
await _purge_restored()
|
||||
await _purge_books(OWNER_USERNAME)
|
||||
yield
|
||||
await _purge_restored()
|
||||
await _purge_books(OWNER_USERNAME)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def source():
|
||||
"""One rule with two snapshots: one written by a user who still exists,
|
||||
one whose actor is already gone.
|
||||
|
||||
Both are needed. With only the attributed version, dropping unmappable
|
||||
rows would pass; with only the orphaned one, so would dropping the actor
|
||||
from every row.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER_USERNAME)
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
|
||||
async with async_session() as s:
|
||||
book = Rulebook(owner_user_id=uid, title="Environment facts")
|
||||
s.add(book)
|
||||
await s.flush()
|
||||
topic = RulebookTopic(rulebook_id=book.id, title="ci")
|
||||
s.add(topic)
|
||||
await s.flush()
|
||||
rule = Rule(
|
||||
topic_id=topic.id,
|
||||
title="The runner has no bash",
|
||||
statement="Write every `run:` step in POSIX sh.",
|
||||
verify_with="read the workflow's shell setting",
|
||||
)
|
||||
s.add(rule)
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
RuleVersion(
|
||||
rule_id=rule.id, user_id=uid,
|
||||
title="The runner has no bash",
|
||||
statement="Use sh.",
|
||||
why="the image ships no bash",
|
||||
verify_with="read the workflow's shell setting",
|
||||
tier="always_on",
|
||||
),
|
||||
# The actor is already gone — what SET NULL leaves behind.
|
||||
RuleVersion(
|
||||
rule_id=rule.id, user_id=None,
|
||||
title="The runner has no bash",
|
||||
statement="Use POSIX sh in run steps.",
|
||||
tier="always_on",
|
||||
),
|
||||
])
|
||||
await s.commit()
|
||||
book_id, rule_id = book.id, rule.id
|
||||
|
||||
async with async_session() as s:
|
||||
user_rows = backup._user_rows(
|
||||
[(await s.execute(select(User).where(User.id == uid))).scalars().one()]
|
||||
)
|
||||
book_rows = backup._rulebook_rows(
|
||||
[(await s.execute(select(Rulebook).where(Rulebook.id == book_id)))
|
||||
.scalars().one()]
|
||||
)
|
||||
topic_rows = backup._topic_rows(
|
||||
(await s.execute(
|
||||
select(RulebookTopic).where(RulebookTopic.rulebook_id == book_id)
|
||||
)).scalars().all()
|
||||
)
|
||||
rule_rows = backup._rule_rows(
|
||||
[(await s.execute(select(Rule).where(Rule.id == rule_id))).scalars().one()]
|
||||
)
|
||||
version_rows = backup._rule_version_rows(
|
||||
(await s.execute(
|
||||
select(RuleVersion).where(RuleVersion.rule_id == rule_id)
|
||||
.order_by(RuleVersion.id)
|
||||
)).scalars().all()
|
||||
)
|
||||
# The restore mints a NEW user from the payload, so the restored corpus is
|
||||
# separate from the source one — which is what makes the id assertion
|
||||
# below able to fail.
|
||||
user_rows[0]["username"] = RESTORED_USERNAME
|
||||
|
||||
yield {
|
||||
"payload": {
|
||||
"version": backup.BACKUP_VERSION,
|
||||
"users": user_rows,
|
||||
"rulebooks": book_rows,
|
||||
"rulebook_topics": topic_rows,
|
||||
"rules": rule_rows,
|
||||
"rule_versions": version_rows,
|
||||
},
|
||||
"source_rule_id": rule_id,
|
||||
"source_user_id": uid,
|
||||
}
|
||||
|
||||
async with async_session() as s:
|
||||
book = await s.get(Rulebook, book_id)
|
||||
if book is not None:
|
||||
await s.delete(book)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def restored(source):
|
||||
await backup.restore_full_backup(source["payload"])
|
||||
async with async_session() as s:
|
||||
user = (await s.execute(
|
||||
select(User).where(User.username == RESTORED_USERNAME)
|
||||
)).scalars().first()
|
||||
assert user is not None, "the payload's user was not restored"
|
||||
book = (await s.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user.id)
|
||||
)).scalars().one()
|
||||
topic = (await s.execute(
|
||||
select(RulebookTopic).where(RulebookTopic.rulebook_id == book.id)
|
||||
)).scalars().one()
|
||||
rule = (await s.execute(
|
||||
select(Rule).where(Rule.topic_id == topic.id)
|
||||
)).scalars().one()
|
||||
versions = (await s.execute(
|
||||
select(RuleVersion).where(RuleVersion.rule_id == rule.id)
|
||||
.order_by(RuleVersion.id)
|
||||
)).scalars().all()
|
||||
return {"user": user, "rule": rule, "versions": versions, "source": source}
|
||||
|
||||
|
||||
async def test_both_snapshots_come_back(restored):
|
||||
"""The count first: everything below reads the same on an empty list, so
|
||||
without this a restore that silently dropped both would look like a pass
|
||||
on the shape assertions."""
|
||||
assert len(restored["versions"]) == 2
|
||||
|
||||
|
||||
async def test_the_history_attaches_to_the_RESTORED_rule(restored):
|
||||
"""#3182's trap. The source rule still exists and holds a different id, so
|
||||
a straight-through copy would file this history against it — or against
|
||||
whatever unrelated rule owns that number."""
|
||||
new_rule_id = restored["rule"].id
|
||||
source_rule_id = restored["source"]["source_rule_id"]
|
||||
assert new_rule_id != source_rule_id, (
|
||||
"the restore reused the source id, so this test cannot tell a remap "
|
||||
"from a copy — the fixture is not proving what it claims"
|
||||
)
|
||||
assert {v.rule_id for v in restored["versions"]} == {new_rule_id}
|
||||
|
||||
|
||||
async def test_the_actor_is_remapped_to_the_restored_user(restored):
|
||||
"""`user_id` is an id in the source database too — the same trap, on the
|
||||
column that answers "who changed this"."""
|
||||
attributed = [v for v in restored["versions"] if v.user_id is not None]
|
||||
assert len(attributed) == 1
|
||||
assert attributed[0].user_id == restored["user"].id
|
||||
assert attributed[0].user_id != restored["source"]["source_user_id"]
|
||||
|
||||
|
||||
async def test_a_snapshot_with_no_actor_survives(restored):
|
||||
"""The deliberate divergence from NoteVersion. `user_id` is SET NULL so
|
||||
that history outlives the account that wrote it; skipping the row on an
|
||||
unmappable user would throw away exactly what the FK preserves."""
|
||||
orphaned = [v for v in restored["versions"] if v.user_id is None]
|
||||
assert len(orphaned) == 1, (
|
||||
"the version whose actor was already gone did not come back. A rule's "
|
||||
"history is the only record of what it used to say — losing it "
|
||||
"because nobody can be credited is the wrong trade."
|
||||
)
|
||||
assert orphaned[0].statement == "Use POSIX sh in run steps."
|
||||
|
||||
|
||||
async def test_the_text_survives(restored):
|
||||
"""The whole point of the table: what the rule USED to say. A restore that
|
||||
kept the rows and lost their wording would preserve a changelog of empty
|
||||
entries."""
|
||||
by_statement = {v.statement: v for v in restored["versions"]}
|
||||
assert set(by_statement) == {"Use sh.", "Use POSIX sh in run steps."}
|
||||
assert by_statement["Use sh."].why == "the image ships no bash"
|
||||
assert by_statement["Use sh."].verify_with == (
|
||||
"read the workflow's shell setting"
|
||||
)
|
||||
assert by_statement["Use sh."].tier == "always_on"
|
||||
@@ -23,7 +23,7 @@ def test_backup_version_is_current():
|
||||
|
||||
(Named for the number it asserted until v10, which is exactly the drift a
|
||||
name-carrying-a-value invites; it now says what it checks.)"""
|
||||
assert backup.BACKUP_VERSION == 12
|
||||
assert backup.BACKUP_VERSION == 13
|
||||
|
||||
|
||||
def _exportable_note(**over):
|
||||
@@ -134,6 +134,7 @@ def _column_guard_targets():
|
||||
from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic, RuleRelation
|
||||
@@ -154,6 +155,7 @@ def _column_guard_targets():
|
||||
"rulebooks": (Rulebook, backup._rulebook_rows),
|
||||
"rulebook_topics": (RulebookTopic, backup._topic_rows),
|
||||
"rules": (Rule, backup._rule_rows),
|
||||
"rule_versions": (RuleVersion, backup._rule_version_rows),
|
||||
"systems": (System, lambda rows: backup._system_rows(rows, {})),
|
||||
"canonical_systems": (CanonicalSystem, backup._canonical_system_rows),
|
||||
"record_systems": (RecordSystem, backup._record_system_rows),
|
||||
|
||||
Reference in New Issue
Block a user