Every lesson since the schema has been rehearsing for this one. Lesson 3
promised the autoincrement would delete a class of code. Lesson 8 introduced
the transaction. Lesson 9 left a Place Order button unwired. Today the
button works: one click, and the order row, its line items, a status row,
and the inventory decrements all land together — or none of them do.
Checkpoint:
lesson-10.
All or nothing, spelled out
db::order::place is the longest function in the port, and it’s still just
a straight line: begin, insert, loop, insert, commit.
let mut tx = pool.begin().await?;
let order_id: i64 = sqlx::query_scalar!(
r#"INSERT INTO orders (userid, orderdate, /* …20 columns… */)
VALUES (?1, date('now'), /* … */)
RETURNING orderid as "orderid!""#,
/* bindings from the draft and cart */
)
.fetch_one(&mut *tx)
.await?;
RETURNING orderid is the lesson-3 promise kept. The original gets its
order number by reading a counter from the sequence table, incrementing
it, and writing it back — application code doing id generation with a race
condition for a bonus. Our id comes back from the insert itself, assigned
by the database, unique by construction. The port has no getNextId()
because there is nothing to port it to.
Then the loop, where each cart line writes two statements:
sqlx::query!(
"INSERT INTO lineitem (orderid, linenum, itemid, quantity, unitprice)
VALUES (?1, ?2, ?3, ?4, ?5)", /* … */
).execute(&mut *tx).await?;
let updated = sqlx::query!(
"UPDATE inventory SET qty = qty - ?2 WHERE itemid = ?1 AND qty >= ?2",
line.item_id,
quantity
).execute(&mut *tx).await?.rows_affected();
if updated == 0 {
return Err(PlaceOrderError::OutOfStock(line.item_id.clone()));
}
That UPDATE is the sharpest line in the lesson. The qty >= ?2 in the
WHERE clause makes the decrement and the stock check one atomic
statement — there is no read-then-write gap for a concurrent purchase to
slip through. If the stock isn’t there, no row matches, rows_affected is
zero, and we return an error.
And here is Rust being quietly excellent: that early return is the
rollback. tx goes out of scope without commit, and a dropped
transaction rolls back — the order row, the earlier line items, the
earlier decrements, all of it. No try/catch/rollback choreography, no
@Transactional proxy magic deciding which exceptions count. The test
suite proves it: drain EST-20 to zero, try to buy it along with two
angelfish, and afterward there are no orders and the angelfish count is
untouched.
The moment of truth re-checks everything
The handler doesn’t trust the confirmation page:
let Some(draft) = session.get::<OrderDraft>(checkout::DRAFT_KEY).await? else {
return Ok(Redirect::to("/checkout").into_response());
};
The confirm page’s checks were a different request. Between then and this
click, the cart may have emptied, the draft may have gone. Re-checking
costs four lines and buys a pleasant surprise: after a successful order
clears the draft, a refresh of the POST finds no draft and bounces to
/checkout — the double-submit problem solved by state hygiene rather
than by clever tokens.
On success: draft removed, cart reset, redirect to the placed page. On
OutOfStock: a page that says so and promises, truthfully, that nothing
was written.
Guessable numbers, guarded doors
Order ids are now sequential: yours is #1, the next buyer’s is #2. Which
means anyone can type /orders/placed/1. The placed page’s handler is
where that stops mattering:
match db::order::owner(&pool, order_id).await? {
Some(owner) if owner == username => { /* render */ }
_ => Err(AppError::NotFound),
}
This class of bug — authenticated user, unauthorized object — is IDOR, insecure direct object reference, and it has headlined real breaches at companies with security teams. Two details to copy: the check happens in the handler that serves the data, not in some upstream filter that might not cover a future route; and “not yours” returns the same 404 as “not real,” because a 403 would confirm the order exists. Lesson 11’s history pages inherit this pattern wholesale.
Build it
| File | Action | What goes in it |
|---|---|---|
src/db/order.rs |
write | PlaceOrderError, place (the shape above — the 22-column INSERT is tedious; copying it is honest), owner; three tests: everything-writes, out-of-stock-rolls-back, ids-ascend |
src/db/mod.rs |
modify | pub mod order; |
src/web/orders.rs |
write | place (re-check, call, clear, redirect; OutOfStock renders the failed page) and placed with the ownership match |
src/web/cart.rs |
modify | save becomes pub(crate) |
src/web/mod.rs |
modify | mod orders;, the POST /orders and GET /orders/placed/{id} routes |
templates/confirm.html |
modify | The placeholder becomes the real Place Order form |
templates/order-placed.html, order-failed.html |
write | Thank-you with the order number; the honest failure page |
.sqlx/ |
generated | cargo sqlx prepare |
Done when: a full flow (sign in → add → checkout → confirm → Place
Order) lands on “Order #1”; the cart is empty afterward; re-POSTing
/orders bounces to /checkout instead of double-ordering; sqlite3 jpetstore.db 'SELECT qty FROM inventory WHERE itemid="EST-1"' shows the
decrement; signing in as ACID and visiting your order’s URL gets a 404;
and cargo test shows 25 passing.
Answer key: lesson-09...lesson-10.
Challenge: the price you were promised
The cart stores unit prices captured at add time (lesson 7’s decision),
and place writes them into lineitem unchecked. Suppose an admin
repriced EST-1 between add and checkout. Decide what should happen —
honor the cart price, or re-fetch and make the buyer re-confirm — then
implement it inside the transaction. There’s no universally right answer;
there is a right place to enforce whichever you choose.
Next lesson: order history and the account page — reading back what this lesson wrote, and feature parity with the original.