fix(api): a token hiccup is not "reconnect"; reorders and approvals are one unit each

Deep review of the debt burn:
- `refusal_reason` told the user to reconnect on ANY token-endpoint failure — a 5xx, a 429, a
  misconfigured client — while the grant stayed and the account still read as connected, with no
  connect button. Only `invalid_grant` (the one that drops the grant) means reconnect now.
- `pins.reorder` writes only the rows that change, so two overlapping reorders each skipped rows
  that matched in their own stale snapshot and interleaved into two pins on one slot. It locks the
  user's pins first; the second waits and deals from the first's result.
- Adding a whitelist entry committed the invite and the activation it implies separately, and its
  audit row was written before a new invite had an id (target NULL). One transaction, flushed for
  the id.
- The mutation convention test skipped async routes and dicts returned by name: request-access,
  purge-discovery and the subscription sync answered without `ok`. They do now, and the test
  covers both. The helper-commit check looked only at `_`-names; it reads every non-route
  function, which listed two deliberate committers (upsert_pending_invite, ensure_welcome) — now
  allowlisted with their reasons.
This commit is contained in:
2026-09-19 19:19:29 +02:00
parent 99df44d7fb
commit 955c8c3445
9 changed files with 130 additions and 33 deletions
+10 -10
View File
@@ -84,7 +84,10 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
current = list_pins(db, user)
# Locked: the rows it leaves alone are part of the answer too. Two overlapping reorders each
# skipped the rows that already matched in their own snapshot and interleaved into two pins on
# one slot; the second now waits and deals from the first one's result.
current = list_pins(db, user, lock=True)
owned = {p.id for p in current}
listed: list[int] = []
for i in ids:
@@ -100,16 +103,13 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
db.flush()
def list_pins(db: Session, user: User) -> list[ComfortPin]:
return list(
db.execute(
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
)
.scalars()
.all()
def list_pins(db: Session, user: User, *, lock: bool = False) -> list[ComfortPin]:
stmt = (
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
)
return list(db.execute(stmt.with_for_update() if lock else stmt).scalars().all())
def next_position(db: Session, user: User) -> int: