feat: paginate mobile Human Gate history (Closes #1444)
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m21s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-26 21:10:10 +00:00
1 parent 57c7373079
commit 0f1cac0e25
21 files changed
+325 -51

No files matched your search

+59 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import hashlib
import json
import re
@@ -69,6 +70,9 @@ class HumanGateStore:
);
CREATE INDEX IF NOT EXISTS human_gates_queue
ON human_gates(login, state, priority DESC, created_at, id);
CREATE INDEX IF NOT EXISTS human_gates_decision_history
ON human_gates(login, updated_at DESC, id DESC)
WHERE state IN ('released','held','superseded');
CREATE TABLE IF NOT EXISTS human_gate_intake_keys (
login TEXT NOT NULL, idempotency_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, gate_id TEXT NOT NULL,
@@ -78,6 +82,8 @@ class HumanGateStore:
sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL,
action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS human_gate_history_gate_sequence
ON human_gate_history(gate_id, sequence);
CREATE TABLE IF NOT EXISTS human_gate_receipts (
receipt_id TEXT PRIMARY KEY, login TEXT NOT NULL,
idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
@@ -262,13 +268,64 @@ class HumanGateStore:
row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone()
return self._present(connection, row, history=True)
def list(self, login: str, *, state: str = "pending", limit: int = 100) -> dict:
@staticmethod
def _history_cursor(login: str, updated_at: float, gate_id: str) -> str:
principal = hashlib.sha256(login.encode()).hexdigest()[:16]
raw = _canonical([principal, updated_at, gate_id]).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
@staticmethod
def _parse_history_cursor(login: str, cursor: str) -> tuple[float, str]:
try:
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
principal, updated_at, gate_id = json.loads(raw)
expected = hashlib.sha256(login.encode()).hexdigest()[:16]
if (
principal != expected
or not isinstance(updated_at, (int, float))
or not isinstance(gate_id, str)
or not gate_id
):
raise ValueError
return float(updated_at), gate_id
except (ValueError, TypeError, json.JSONDecodeError, UnicodeDecodeError) as error:
raise GateValidationError("history cursor is invalid") from error
def list(
self, login: str, *, state: str = "pending", limit: int = 100,
cursor: str | None = None,
) -> dict:
login = self._login(login)
if state not in _STATES and state != "all":
if state not in _STATES and state not in {"all", "history"}:
raise GateValidationError("state is invalid")
limit = min(max(int(limit), 1), 100)
with self._connect() as connection:
pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0]
if state == "history":
args: list[object] = [login]
cursor_clause = ""
if cursor:
updated_at, gate_id = self._parse_history_cursor(login, cursor)
cursor_clause = " AND (updated_at < ? OR (updated_at = ? AND id < ?))"
args.extend([updated_at, updated_at, gate_id])
rows = connection.execute(
"SELECT * FROM human_gates WHERE login=? "
"AND state IN ('released','held','superseded')" + cursor_clause +
" ORDER BY updated_at DESC, id DESC LIMIT ?",
(*args, limit + 1),
).fetchall()
visible = rows[:limit]
next_cursor = None
if len(rows) > limit:
last = visible[-1]
next_cursor = self._history_cursor(login, last["updated_at"], last["id"])
return {
"pending_count": pending_count,
"items": [self._present(connection, row) for row in visible],
"next_cursor": next_cursor,
}
if cursor:
raise GateValidationError("history cursor is invalid")
where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state])
rows = connection.execute(
f"SELECT * FROM human_gates WHERE {where} "
+4 -1
View File
@@ -1890,10 +1890,13 @@ async def list_human_gates(
request: Request,
state: str = Query(default="pending"),
limit: int = Query(default=100, ge=1, le=100),
cursor: str | None = Query(default=None, min_length=1, max_length=512),
):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit)
result = await asyncio.to_thread(
_human_gate_store().list, login, state=state, limit=limit, cursor=cursor
)
except (GateValidationError, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})