Repository navigation
Mutations, transactions and connections guides, and the multiple-databases how-to #711
Description
Parent
What to build
Copying any block from these pages raises RuntimeError: No database route, because no page shows the session the snippets run inside. The Connections guide still calls sessions "recommended" and says calls without one use the default connection; the identity map is described as per-connection (it is per session); the mutations page embeds a Customer model it never shows; the transfer example never warns that a rollback leaves Python objects modified.
Connections › Sessions becomes the one explanation of sessions, linked from every page that shows a fragment. Each page gets one running example, shown on the page. Named connections are explained once (Connections) and the multiple-databases how-to links to it instead of repeating the snippets.
Pages: guide/mutations.md, guide/transactions.md, guide/connections.md, howto/multiple-databases.md
Acceptance criteria
- Every fact on the page matches the code at the time of the PR; each High finding in the audit rows below is fixed, and each Medium/Low finding is fixed or answered in the PR body with the reason it was left.
- Each page is one kind (tutorial, how-to, reference or explanation) and opens with one sentence saying what it covers, then an example on the first screen (writing-for-humans rules 1–2).
- Each page uses one running example, declared once near the top and reused; each section shows only what changed (rule 3).
- Every code block has a lead-in sentence ending in a colon and a result sentence after it, and shows its result: printed output, or the asserted value inside the embedded snippet range (rules 7–8). Snippet
end:markers sit below the assert or print that holds the result. - Every copyable block either shows the
async with engines.session():wrapper or sits under a sentence that says it runs inside one and links to Connections › Sessions; the page's first block shows the wrapper. - Every inline Python block that does real work runs under the harness (
{test=run prelude=<name>}) or moves into an example script; a block left compile-only says why in its fence marker. - Every lambda passed to a query method names its parameter after the model in lowercase singular (AGENTS.md I-8);
python -m tests._docs_checksreports no violation for the owned files. - Every error check in an owned example fails when the expected error is not raised (
try/except/else: raise AssertionError, or the harness helper). - Field-declaring examples show both declaration styles as tabs, each backed by a runnable script (AGENTS.md I-7), and the twins use the one Annotated idiom recorded in the docs conventions.
- Headings are short sentence-case noun phrases naming the feature (rule 5); how-to titles name the goal (rule 24); no minimising words (rule 17); admonitions follow rule 20.
- No ADR numbers, issue numbers, codenames, dated or release-bound notes, or contributor-only references in page text; version markers are self-contained blocks at the end of a section.
- Reference material that has a home in Reference is linked, not repeated; each topic ends with a pointer to its reference entry (rule 4).
Files owned
- Modify:
docs/pages/guide/mutations.md,docs/pages/guide/transactions.md,docs/pages/guide/connections.md,docs/pages/howto/multiple-databases.md,docs/examples/mutations.py,docs/examples/transactions.py,docs/examples/multiple_databases.py
Interfaces
- Consumes: docs conventions (AGENTS.md ticket (AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703))
- Consumes: harness (harness ticket (Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704))
- Consumes: quickstart example as rewritten by the get-started ticket (Get started pages: home page and README, Why Ferro, installation and quickstart #705)
- Produces: Section anchor
#sessionson the Connections guide: the one explanation of sessions, linked by every page ticket.
Test scenarios
- Happy path: the strict site build succeeds and every snippet range on the owned pages renders without leading indentation.
- Happy path: every owned example script runs to completion under
tests/test_docs_examples.py, and every inline block marked{test=run ...}runs. - Error path: deleting the
raisean owned example's error check expects makes that example's test fail. - Edge case:
python -m tests._docs_checksover the owned files exits 0; renaming one lambda parameter totmakes it exit 1 naming the file and line.
Verify
uv run --no-sync zensical build --clean --strict
uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p no:cacheprovider -n 8
uv run --no-sync python -m tests._docs_checks docs/pages/guide/mutations.md docs/pages/guide/transactions.md docs/pages/guide/connections.md docs/pages/howto/multiple-databases.md docs/examples/mutations.py docs/examples/transactions.py docs/examples/multiple_databases.pyBlocked by
- Docs site config: Zensical features, Markdown extensions, API reference options and a strict build #701, AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703, Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704, Get started pages: home page and README, Why Ferro, installation and quickstart #705
Audit findings
Rows from the docs audit this ticket covers (file, line, rule, fix)
docs/pages/guide/mutations.md
- Kind (rule 1): mixed. Mostly how-to, with reference material inline: the six-bullet
only=/exclude=rules list (L102–109), the "Things to know" list, and the.merge()edge cases (L222–224). - Running example (rule 3): none. The page uses
Post/Author(fromquickstart.py),Customer(frommutations.py, a model the page never shows),Article,UserandConversation. - Verdict: The facts are accurate against
models.py:create,save,upsert,bulk_create,get_or_create,refreshraisingRuntimeError, andsaveraisingModelDoesNotExist. The page still needs restructuring. The reader never sees theCustomermodel behind most snippets, the sections run in no clear order, and the "Recipe updates" section is hard to read. C - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 205 | stale fact | "By default Ferro keeps a per-connection identity map … create()/save() register instances in it." The map is session-scoped: the connect() docstring says "operations outside a session never cache", and concepts/identity-map.md agrees. |
"Inside a session, Ferro keeps an identity map: loading the same primary key twice yields the same Python object." |
| H | whole page | correctness | No excerpt shows async with engines.session():, and the page never says a session is required. A reader who copies a block gets RuntimeError: No database route. |
Add one sentence plus a block near the top showing the session wrapper, and link to Connections › Sessions. |
| H | 19–33, 82–175 | 3, 9 | Most snippets use Customer (email: str = Field(unique=True), plan, views, updated_at), but the model is never shown. The reader cannot see why create(email=…) raises UniqueViolationError or where views comes from. The create snippet instead pulls quickstart.py:create, which uses Post/Author, a second cast. |
Make Customer the running example. Embed mutations.py:models under Assignment/Annotated tabs. That needs a new mutations_annotated.py, per I-7, because the model uses Field(unique=True). Replace the quickstart snippet with a Customer create and bulk_create. |
| M | 7–11 | 1, 10 | The table's third column header, "On a primary-key / unique conflict", does not fit the save() row, which reads "INSERT if never persisted, otherwise UPDATE". bulk_create, which the next section introduces, is missing from the table. |
Rename the column to "Behaviour" and add a bulk_create row. |
| M | 5 | 19 | "none of them ever silently does more work than you asked for" is a slogan, not information. | Delete it. The table says the same thing. |
| M | 13–260 | 4 | The section order jumps around: create → save → upsert → get-or-create → batch update → refresh → delete → errors → bulk/identity map → recipe updates. Recipe updates (expression update()) sit 60 lines from Batch Updates, and the bulk_create identity-map caveat sits 190 lines from bulk_create. |
Use create (+ bulk_create + its identity-map caveat) → save (+ partial) → batch update/delete (+ recipe updates + the limit guard) → delete one instance → upsert / get_or_create / update_or_create → refresh → errors. |
| M | 216–224, 260 | 14, 15, 22 | The prose is hard to parse and uses internal jargon. "Keyword update(name="x") stays literals-only." "After connect, SQLite, a list-typed column, or a list patch fail at build time (list concat is .concat(), not shipped yet)." "Multiplication and string concat are not in this door yet." ("door" is internal vocabulary.) |
"update() with keyword arguments sets literal values. To compute a value in the database, pass a lambda that returns a dict:" … ".merge() raises when the query is built if the connection is SQLite, the column holds a list, or the patch is a list." Drop the "not shipped yet" and "not in this door yet" remarks, or move them to the roadmap. |
| M | 237–238, 255–256 | 9 | The Conversation block uses the undefined names cid, run_id and entry. It only compiles, it does not run, and the .merge() usage has no runnable snippet anywhere (mutations.py runs on SQLite, where .merge() raises). |
Define the values in the block (cid = 1, run_id = "r1", entry = {"role": "user"}). Mark the block Postgres-only in its lead-in. |
| M | 19, 27, 82, 92, 98, 120, 136, 144, 154, 165, 183, 197, 209, 218 | 7 | Rule 7: 14 of 16 code blocks lack a result sentence. Results live in asserts and # comments, or the next paragraph moves to a new topic (L23 "Batch size is unbounded…", L124 "Things to know:"). |
After each block, add one sentence on what changed. For example, after L120: "The second call updates row 100 in place; name goes back to "" because the whole row is written." |
| M | 82, 92, 154, 218 | 13 | The page talks in SQL (UPDATE ... WHERE pk = ?) but never shows the SQL a call emits. |
Show the SQL beside the calls: save(only={"name","updated_at"}) → UPDATE "customer" SET "name" = ?, "updated_at" = ? WHERE "id" = ?, and recipe update → SET "views" = "views" + 1, "updated_at" = CURRENT_TIMESTAMP. |
| M | 197 | 8 | The handling-errors snippet prints exc.sqlstate, exc.constraint and exc.driver_message, but the page never shows the output. |
Add the SQLite output under the block (2067 / None / UNIQUE constraint failed: customer.email). |
| M | 102–109 | 1 | Six rule bullets in the guide restate the save() docstring, which is reference material. |
Keep two bullets (persisted-only, one write-set at a time) and link to Model.save for the rest. |
| L | 107 | 15 | "A PK-only model's bare save() is still today's existence check." "Today's" makes it sound like a changelog entry, and the term is never explained. |
Delete it, or say what it does: "…only checks that the row exists." |
| L | 23 | 16 | "— an implementation detail you never see" is filler. | Delete it. |
| L | 113, 147, 158 | 20 | Three admonitions are used correctly (Note, Note, Warning). The bulk_create identity-map caveat (L207) is a surprising behaviour and should be a Note. |
Make L207 a !!! note. |
| L | 13, 75, 131, 150, 169, 193, 203 | 5 | The headings are Title Case. The rule asks for sentence case with the API in code: "Get-or-Create" → "get_or_create", "Refreshing from the Database" → "refresh()". |
Change them to sentence case with the API name in code. |
docs/pages/guide/transactions.md
- Kind (rule 1): how-to plus explanation (nesting semantics, patterns). The mix fits the topic.
- Running example (rule 3): partial. The snippets use
Account(checking/savings), but the inline blocks switch toMetric,AuditLog/RecordandPost.body_html.Posthas nobody_htmlfield anywhere in the docs. - Verdict: The claims hold. I verified that unqualified ops inside
transaction(using=…)are pinned, that routing to another connection raisesValueError, and that an inner savepoint rolls back alone. Two things are missing: the session requirement, and a warning that rollback does not restore in-memory instances, which the page's owntransfer()example walks into.immediate=Trueis undocumented. B - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 104–116 | correctness | transfer() decrements src.balance in memory, then raises. The DB rolls back but src keeps the decremented balance, so a caller who retries or saves src later writes the wrong value. The page says "nothing was committed" and stops there. (save()'s docstring also notes that a row inserted in a rolled-back transaction leaves the instance marked persisted.) |
Add a Warning after the block: "Rollback undoes the database, not your Python objects. src.balance is still decremented; call await src.refresh() before reusing it." |
| H | 7, 62, 88, 103 | correctness | No block shows the required engines.session(). A bare async with transaction(): outside a session raises RuntimeError: No database route (verified). |
State it in the intro and show one complete block with the session. L33–39 already shows engines.session("analytics") and can be moved up and generalised. |
| M | 23–27 | 7, 8 | The text says unqualified operations are pinned and that routing elsewhere raises. The snippet (multiple_databases.py:transaction) shows neither: it uses Metric.using("analytics") explicitly and never routes elsewhere. |
Change the snippet to await Metric.create(...) (unqualified) and add a try: await Metric.using("app").create(...) except ValueError line. That raise is verified. |
| M | 72–76 | 9, 18 | The savepoint example catches ValueError "this row rolled back". A Pydantic ValidationError is a ValueError, but it fires before any SQL, so there is nothing to roll back. A DB failure raises IntegrityError, which this except does not catch. |
Catch IntegrityError (from ferro import IntegrityError) so the comment is true. |
| M | — | coverage | transaction(immediate=True) (SQLite BEGIN IMMEDIATE, avoids SQLITE_BUSY on read-then-write) is missing from the guide. No page in docs/pages mentions it. |
Add a short "SQLite write locks: immediate=True" section. |
| M | 7, 17 | 7 | Both opening snippets lack a lead-in, and the result lives only in a # comment. |
"Move 25 from checking to savings in one unit:" … "Both rows change together, or neither does." |
| L | 29, 99, 82 | 5 | "Sessions + Transactions" uses a symbol. "Keep transactions short" and "Error handling" sit under "Patterns" as instructions, not names. | "Transactions in a session", "Short transactions", "Catching errors inside a transaction". |
| L | 89–97 | 3 | Post.body_html does not exist in any docs model. |
Use Account or the guide's running example. |
docs/pages/guide/connections.md
- Kind (rule 1): mixed. How-to (connecting, Supabase), reference (options table duplicating
api/connection.md'sconnect/PoolConfigdocstrings), and an upgrade note (L150–151). - Running example (rule 3): none. The page uses
User,Metric,Postand a TextualApp. - Verdict: Holds the most stale facts of my six pages. It calls sessions "recommended" when they are required, says unqualified operations "use the default connection" when they raise outside a session, and calls the identity map per-connection. Sessions, the thing every reader needs first, come sixth. C
- Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 104–106 | stale fact | "## Sessions (recommended)" / "Session-scoped routing is now the preferred runtime model". Since 0.13 a session (or using=/session=) is required, and L150 of the same page says so. |
Heading "Sessions"; "Every ORM and raw-SQL call runs in a session. Open one with async with ferro.engines.session():". Move the section directly after "Connecting". |
| H | 155 | stale fact | "Unqualified operations (User.all(), top-level execute(...)) use the default connection." Outside a session they raise RuntimeError (verified). The default connection decides which connection engines.session() with no name opens. |
"engines.session() with no name opens a session on the default connection. The default is set three ways:" |
| H | 64 | stale fact | The identity_map row says "Keep a per-connection identity map". The connect() docstring says identity maps are session-scoped, and operations outside a session never cache. |
"Sessions on this connection keep an identity map…" |
| H | 112, 123 | I-8 | s.query(User).where(lambda t: t.active == True) and User.where(lambda t: t.active == True) |
lambda user: user.active == True. |
| M | 9–49 | 9 | The Connecting examples call connect() and stop. Nothing on the first screen shows a working query, because that needs a session. |
End "Connecting" with a three-line block: connect → async with engines.session(): → one query. |
| M | 55–66, 70–81 | 1 | The options table and PoolConfig defaults duplicate the API reference. PoolConfig.settings_delivery is missing, so the table is incomplete and will drift. |
Keep url/auto_migrate/name, link the rest to connect and PoolConfig. Mention settings_delivery with a link to the RLS guide. |
| M | 65–66 | 20 | "Added in 0.11.0." sits inline in table cells. | Drop it (pre-1.0 and eight releases ago), or move it to a version marker block. |
| M | 144–146 | 15 | One 60-word sentence on stale ambient sessions and nested close order. | Split it into "If you close a session from another task…" and "With nested sessions, close inner ones first." Or move it to the Session.close reference. |
| M | 150–151 | 1 | An upgrade note inside a guide. | Move it to a Note box at the end of Sessions, or rely on the existing upgrade-guide link. |
| M | 172–187 | 7 | create_tables() returns a PassReport (verified), and the page doesn't say so. |
"create_tables() returns a PassReport of the statements it ran." |
| L | 15, 201 | 19 | "great for tests", "This is handy for" | "In-memory database, for tests"; "Use it for schema-per-tenant setups…" |
| L | 133–134 | — | await self.session.__aenter__() calls a dunder in user code. No public open() exists. |
Flag it to the API owners. Keep the example until they decide. |
| L | 216–217 | 20 | "No disconnect() yet": roadmap material in a Note. |
Keep it if needed, as "Pools close at process exit; reset_engine() tears them down in tests." |
| L | headings | 5 | The headings are Title Case. | Change them to sentence case. |
docs/pages/howto/multiple-databases.md
- Kind (rule 1): mixed: how-to (register, route, transact) with reference (the
ModelConnectionmethod list and routing error rules) and explanation ("Practical Notes"). - Running example (rule 3):
Metricon connectionsappandanalytics. Consistent. - Verdict: The facts check out live (the
ValueErroron a conflictingusing=, theRuntimeErrorwith no route,engines.session("analytics").query(...), theModelConnectionsurface). The page embeds the same two snippets asguide/connections.md#named-connections, one snippet comment is misleading, and the transaction example undercuts its own claim. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 33 | I-8 | s.query(Metric).where(lambda t: t.value > 0) |
lambda metric: metric.value > 0 |
| M | 9–21 vs guide/connections.md:83–101 |
1 | multiple_databases.py:connect and :routing are embedded in both pages with near-identical prose. |
Make the guide section a short summary and link here, or the reverse. Don't keep two copies. |
| M | snippet multiple_databases.py:22 |
correctness | The comment "Writes go to the default ("app") connection unless routed" sits inside engines.session(). Routing there with using("analytics") raises ValueError (confirmed live), so "unless routed" is wrong inside a session. |
"Inside a default session, unqualified calls go to "app"." |
| M | 40–44, snippet :transaction |
3, 11 | The text says everything inside transaction(using=...) inherits the connection, but the snippet still writes Metric.using("analytics").create(...). Live check: an unqualified Metric.create inside the block lands on analytics. |
Show the unqualified call: async with transaction(using="analytics"): await Metric.create(name="clicks", value=42). |
| M | 59 | 18, correctness | "Don't run schema creation concurrently through multiple names that point at the same physical database" gives no reason. connect(auto_migrate=True) / create_tables() run under the run lock (src/ferro/__init__.py:384–395), which should serialise them, so the warning may be stale. |
Verify. Then either drop it, or state the reason and the failure. |
| M | 17 | 1, 15 | One 70-word sentence packs the routing rule, the session rule and the full ModelConnection method list (reference material). |
Split: "Unqualified calls inside engines.session() use the session's connection. Model.using(name) routes one call and needs no session:". Link the method list to the API reference. |
| M | 1 | 24 | Title "Multiple Databases" | # How to use several databases in one app |
| M | 19–23, 40–44 | 7, 8 | The routing and transaction blocks show no results. | Show print(len(app_metrics), len(analytics_metrics)) → 1 1, then after the transaction 2. The script already asserts these. |
| L | 3 | 15 | "Named connections make everything else explicit." is vague. | "Name each further connection and route to it explicitly." |
| L | 5, 15, 36, 46, 61, 69 | 5 | Title Case, and "Practical Notes" names nothing. | Sentence case. "Practical Notes" → "Security and routing rules", and make "Never route from untrusted input" a Warning (the guide already has one). |
| L | 25 | 20 | The ValueError / RuntimeError rules are reference facts embedded in prose. |
Fine as is. Optionally show them as a short REPL transcript (rule 8). |
docs/examples/mutations.py
- Embedded by:
guide/mutations.md(11 sections) - Running example:
Customer. The page never shows it: the model is outside any section, and the page's own inline model isArticle(lines 39–67). - I-7: no
_annotatedtwin is needed, because no Field-declaring section is embedded. That is also the rule 3 problem below. - Verdict: B. Behaviour-accurate (verified: SQLite prints
2067 / None / UNIQUE constraint failed: customer.email), but the reader never seesCustomer, and three error paths cannot fail.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 10–16 | 3 / I-7 | The page's 11 snippets use Customer (plan, views, updated_at, unique email), which the page never declares. |
Add a [start:model] section, plus mutations_annotated.py (it uses Field(unique=True)), and embed both tabs at the top of the page. |
| M | 56–59, 113–118, 136–146 | 9 | create-strict, handling-errors and mutation-guard are try/except with no else failure. If the error stops firing, mutation-guard's whole recovery recipe, including its assert removed == 1, is skipped and the script still passes. |
else: raise AssertionError(...). |
| M | 134–147 | 11 | mutation-guard teaches the recipe as an exception handler: try the forbidden call, recover in except. Readers will copy the try/except. |
Two blocks: the refusal (with its message as output), then the recipe on its own. |
| M | 116–118 | 8 | Expected values are in comments only ("23505" … on Postgres, "2067" on SQLite). |
Show the SQLite output block beside it. |
| L | 79 | 16 | "omitted in-memory fields may diverge": a hedge, though the code proves they do diverge. | "the instance still holds plan='enterprise'; the row holds 'pro'." |
| L | 3–5 | mechanics | A blank line splits stdlib imports (asyncio / datetime). |
Group them. |
docs/examples/transactions.py
- Embedded by:
guide/transactions.md(3 sections) - Verdict: B. Checking/savings transfer is meaningful data, and the rollback assert is inside the section. The
Accountmodel is never shown on the page.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 8–11 | 3 | Account (owner, balance) is outside any section, and the page doesn't declare it. |
Embed a model section (no Field options beyond primary_key, so I-7 tabs are needed only if Field stays). |
| L | 33–40 | 11 | except RuntimeError: pass reads as error swallowing. |
except RuntimeError: ... # the block raised; everything in it rolled back. |
| L | 47–53 | 8 | handle's result (n == 3) is asserted outside the section. |
Move it in. |
docs/examples/multiple_databases.py
- Embedded by:
howto/multiple-databases.md,guide/connections.md,guide/transactions.md - Verdict: B.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| L | 26–27 | 22 | The comment says "a using= call is its own explicit route", but the code uses .using("analytics"); using= is the transaction() keyword. |
".using() is its own explicit route, so it needs no session." |
| L | 23, 28 | 10 | value: float given ints (value=1, value=100), and "signups: 1" is thin data. |
value=12.0 or make value: int. |
| L | 31–33 | 8 | Results asserted outside routing. |
Move them in. |
Activity
0x054 commentedon Oct 19, 2026
Status: verify-passed
Worker: worker-711
Branch:
sdd/711-core-guidesPR: #736
Verify: passed db31fa4
Re-review (reviewer-711): Spec PASS, Quality PASS. The 11 dead anchors from the trial merge are repointed, and the three Minor findings are fixed. The fix diff changes only links outside those fixes, including the docstrings in
models.pyandbuilder.py.db31fa4sits onfeat/docs-rewriteatb8dbf12, so no trial merge was needed. Ready to merge.docs(guide): sessions-first connections, mutations, transactions and …
0x054 commentedon Oct 19, 2026
Merged in #736 into
feat/docs-rewrite