Channels, search and sync do no database work in their routes any more: reads go through catalog/queries, writes through catalog/service, and a live search's ingest is catalog/search. What a catalog route needs from Reader (a playlist's channels, a user's tag links, the feed projection of results) it asks reader/queries and reader/service. The scheduler and the runs page read channel titles and the work queue through catalog/queries. Catalog is fenced below its HTTP layer: its owner filters go through userscope, it reads no other unit's tables, and its ingest jobs commit only where they are named.
116 lines
4.8 KiB
Python
116 lines
4.8 KiB
Python
"""Two rows of CLAUDE.md's canonical-patterns table, held in code rather than prose.
|
|
|
|
A rule stated only in a comment drifts: this one had — 63 mutation responses without `ok`, and
|
|
twelve route helpers that committed on their own, among them an approval whose decision, activation
|
|
and token landed in three separate transactions. The debt burn of 2026-09 fixed the drift; these keep
|
|
it fixed.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
ROUTES = Path(__file__).parent.parent / "app" / "routes"
|
|
MUTATING = {"post", "put", "patch", "delete"}
|
|
|
|
# A route helper that commits where it stands, and why the commit has to be there rather than in
|
|
# the handler. Anything else flushes and leaves the commit to its handler.
|
|
COMMITTING_HELPERS = {
|
|
"auth.py:_issue_token": "the token must be durable before the link that carries it is mailed",
|
|
"auth.py:_consume_token": "burning a single-use token is its own unit: a later failure must "
|
|
"not hand the token back",
|
|
"auth.py:_complete_link": "the tail of the Google callback route, which answers with its result",
|
|
"auth.py:_register_account": "a background task with its own session, i.e. a job wrapper",
|
|
"auth.py:upsert_pending_invite": "commits only when it writes — the contract its two "
|
|
"callers rely on (`_register_account` persists its own flushed user because a no-op here "
|
|
"commits nothing)",
|
|
"debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when "
|
|
"the handler is already unwinding and has no commit left to reach",
|
|
}
|
|
|
|
|
|
def _modules():
|
|
for path in sorted(ROUTES.rglob("*.py")):
|
|
yield path.relative_to(ROUTES).as_posix(), ast.parse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
|
|
# The calls a mutation may answer with in place of an acknowledgement: each describes the resource
|
|
# it wrote (or is the logout's cookie-clearing response). A domain function's REPORT dict is not one
|
|
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
|
|
SERIALIZERS = {
|
|
"JSONResponse",
|
|
"_collection_card",
|
|
"_playlist_card",
|
|
"_serialize",
|
|
"_serialize_folder",
|
|
"serialize",
|
|
"serialize_job",
|
|
"_serialize_profile",
|
|
"_serialize_user",
|
|
"_summary",
|
|
"admin_get_quota",
|
|
"describe",
|
|
"owner_view",
|
|
}
|
|
|
|
|
|
def _called(node: ast.Call) -> str:
|
|
f = node.func
|
|
return f.id if isinstance(f, ast.Name) else getattr(f, "attr", "")
|
|
|
|
|
|
def _is_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
return any(
|
|
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING | {"get", "websocket"}
|
|
for d in fn.decorator_list
|
|
)
|
|
|
|
|
|
def _is_mutation_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
return any(
|
|
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING
|
|
for d in fn.decorator_list
|
|
)
|
|
|
|
|
|
def test_every_mutation_answers_ok():
|
|
"""`{"ok": true, …}` (or `{"deleted": id}`) for every mutation, sync or async — one field a
|
|
client can read the same way everywhere. A serializer's return (SERIALIZERS) is exempt: it
|
|
describes a resource, not an acknowledgement. A dict built elsewhere — returned by name, or
|
|
straight from a domain call — is not: both hid acknowledgements without `ok` from a
|
|
literal-only scan, so such a dict is spread into a literal."""
|
|
missing = []
|
|
for name, tree in _modules():
|
|
for fn in ast.walk(tree):
|
|
if not (isinstance(fn, FUNCTIONS) and _is_mutation_route(fn)):
|
|
continue
|
|
for node in ast.walk(fn):
|
|
if not isinstance(node, ast.Return):
|
|
continue
|
|
if isinstance(node.value, ast.Call) and _called(node.value) in SERIALIZERS:
|
|
continue
|
|
keys = (
|
|
{k.value for k in node.value.keys if isinstance(k, ast.Constant)}
|
|
if isinstance(node.value, ast.Dict)
|
|
else set()
|
|
)
|
|
if not keys & {"ok", "deleted"}:
|
|
missing.append(f"{name}:{node.lineno} {fn.name}")
|
|
assert missing == []
|
|
|
|
|
|
def test_route_helpers_flush_and_leave_the_commit_to_the_handler():
|
|
"""Every function in a route module that is not itself a route is a helper — whatever its name
|
|
(two public ones committed unseen while this looked only at `_`-names) and sync or async."""
|
|
committing = set()
|
|
for name, tree in _modules():
|
|
for fn in ast.walk(tree):
|
|
if isinstance(fn, FUNCTIONS) and not _is_route(fn):
|
|
if any(
|
|
isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "commit"
|
|
for n in ast.walk(fn)
|
|
):
|
|
committing.add(f"{name}:{fn.name}")
|
|
assert committing == set(COMMITTING_HELPERS)
|