Security and production workflow · Lesson 32 155 min
Production data project
This is one shop, not a new language. You already have tables, keys, joins, views, transactions, indexes, EXPLAIN, backups, sessions, binds, and triggers. The project is to put them on the same catalog and refuse to ship a checkout that only works in the happy-path screenshot.
Product brief
Workshop desk sells notebooks, pens, and mugs. Customers have a stable id and a unique email. The catalog has a sku, a display name, a live list price, and on-hand stock. An order has a customer and a date. Each line names a sku, a quantity, and the unit price at sale time. The website login may read names, not costs. Checkout is one all-or-nothing write.
SQL BROWSER RUNNER
Create the shop and list stored objects
Four tables, seed orders, user_version 1. Triggers come later so history is not replayed.
Edit the query, predict the rows it will return, then run it.
Read the shop the way a report does
Alonzo never ordered. A LEFT JOIN keeps him with spent 0. Ada spent 1200 on one notebook. Grace spent 400 on a pen. COUNT(DISTINCT o.order_id) stays an order count when a header has several lines. Revenue uses qty * unit_cents from the line, not today's list price.
Edit the query, predict the rows it will return, then run it.
The sold price is a different fact from the shelf price
Bump NB-1 to 1500. Order 1 still says 1200. That is the normalization lesson: do not store the live list price as if it were the receipt. If you reported revenue from products.listed_cents, yesterday's sale would change when marketing edits the catalog.
SQL BROWSER RUNNER
Raise the list price without rewriting the receipt
Edit the query, predict the rows it will return, then run it.
Storefront columns and a session are two locks
catalog_store is sku and name. The clerk SELECT does not need cost. The second query asks for order 2 (Grace) while the session row is Ada. The join to session_customer returns no rows. Bound ids without that join would still leak. PostgreSQL could enforce this with row-level security; here you write the join every time.
SQL BROWSER RUNNER
List the storefront, then refuse Grace's order
View without listed_cents. Order 2 plus session 1 should count as 0.
PRAGMA foreign_keys =ON;
PRAGMA user_version =1;CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
email TEXTNOTNULLUNIQUE,
display_name TEXTNOTNULL);CREATETABLE products (
sku TEXTPRIMARYKEY,
name TEXTNOTNULL,
listed_cents INTEGERNOTNULLCHECK(listed_cents >=0),
stock INTEGERNOTNULLCHECK(stock >=0));CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULLREFERENCES customers(customer_id),
placed_on TEXTNOTNULL);CREATETABLE order_lines (
order_id INTEGERNOTNULLREFERENCES orders(order_id),
sku TEXTNOTNULLREFERENCES products(sku),
qty INTEGERNOTNULLCHECK(qty >0),
unit_cents INTEGERNOTNULLCHECK(unit_cents >=0),PRIMARYKEY(order_id, sku));CREATETABLE session_customer (
customer_id INTEGERNOTNULLREFERENCES customers(customer_id));CREATETABLE order_audit (
order_id INTEGERNOTNULL,
customer_id INTEGERNOTNULL);INSERTINTO customers VALUES(1,'ada@example.com','Ada'),(2,'grace@example.com','Grace'),(3,'ada.obrien@example.com','Ada O''Brien'),(4,'alonzo@example.com','Alonzo');INSERTINTO products VALUES('NB-1','SQL notebook',1200,5),('PEN-2','Gel pen',400,2),('MUG-9','Mug',900,8);INSERTINTO session_customer VALUES(1);INSERTINTO orders VALUES(1,1,'2026-09-01');INSERTINTO order_lines VALUES(1,'NB-1',1,1200);INSERTINTO orders VALUES(2,2,'2026-09-02');INSERTINTO order_lines VALUES(2,'PEN-2',1,400);CREATEVIEW catalog_store ASSELECT sku, name FROM products;SELECT sku, name FROM catalog_store ORDERBY sku;SELECTCOUNT(*)AS grace_order_visible
FROM orders AS o
JOIN session_customer AS s ON s.customer_id = o.customer_id
WHERE o.order_id =2;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Checkout is one transaction plus reactions
Ada buys a mug. You start a transaction, insert order 3, insert the line with unit_cents copied from the catalog at that moment (900), and commit. The audit trigger records the header. The stock trigger subtracts qty from MUG-9 (8 → 7). If the line insert failed, ROLLBACK would drop the header too. Seed orders were inserted before the triggers so historical lines do not decrement stock a second time.
SQL BROWSER RUNNER
Sell Ada a mug in one COMMIT
Triggers after seed. BEGIN, order 3, mug line, COMMIT.
Edit the query, predict the rows it will return, then run it.
Lookups still bind
Ada O'Brien's email is data. params stands in for the driver bind from the injection lesson. Join it. Do not build WHERE email = ' plus the form. Identifiers you cannot bind (sort keys) still go through an allowlist; this lookup only needs a value.
Edit the query, predict the rows it will return, then run it.
Prove the access path before you ship the lookup
sku is the primary key. EXPLAIN QUERY PLAN should SEARCH, not SCAN, for WHERE sku = 'NB-1'. Do not ANALYZE this tiny catalog; on a handful of rows the planner may prefer a scan after statistics. Production still needs the index (the PK is one) and a plan you re-read when the table grows.
SQL BROWSER RUNNER
EXPLAIN the sku lookup, then return the row
QUERY PLAN first so a failed SELECT cannot hide it.
Edit the query, predict the rows it will return, then run it.
Version the schema and keep a logical copy
This box cannot copy a .db file. It can snapshot rows and bump user_version. CREATE TABLE products_backup AS SELECT * FROM products is a logical copy you could dump. integrity_check should return ok. In PostgreSQL you would use pg_dump and a migrations table; the idea is the same: know which schema you restored, and prove the copy.
Edit the query, predict the rows it will return, then run it.
A new column is a migration, not a surprise
reorder_at is the stock level that should page a clerk. ADD COLUMN ... DEFAULT 2 fills existing rows. PEN-2 is tighter, so backfill 3. Bump user_version so the next deploy knows this catalog is on 2. Rename-style changes still need a table rebuild, as in the backups lesson.
Edit the query, predict the rows it will return, then run it.
What you should be able to do
Draw customers, products, orders, and order_lines with keys and CHECKs.
Report with LEFT JOIN so missing orders stay visible.
Keep unit_cents as a snapshot.
Hide cost behind a view and filter by session.
Checkout in one transaction with audit and stock triggers.
Bind values, EXPLAIN a PK lookup, and version a backup or ALTER.
Independent lab: Ada buys a pen
PEN-2 has stock 2 and listed 400. Attach the decrement trigger, start a transaction, insert order 3 for session customer 1, insert one line at the live list price, commit. Show only Ada's new line (join session_customer), remaining stock, and PRAGMA integrity_check. Optional: BEFORE INSERT on order_lines that RAISEs when NEW.qty exceeds stock—run a failing qty last so this runner does not hide the successful SELECT.
SQL BROWSER RUNNER
Complete Ada's pen checkout
Seed shop is ready. You add trigger, transaction, and proof SELECTs.
PRAGMA foreign_keys =ON;
PRAGMA user_version =1;CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
email TEXTNOTNULLUNIQUE,
display_name TEXTNOTNULL);CREATETABLE products (
sku TEXTPRIMARYKEY,
name TEXTNOTNULL,
listed_cents INTEGERNOTNULLCHECK(listed_cents >=0),
stock INTEGERNOTNULLCHECK(stock >=0));CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULLREFERENCES customers(customer_id),
placed_on TEXTNOTNULL);CREATETABLE order_lines (
order_id INTEGERNOTNULLREFERENCES orders(order_id),
sku TEXTNOTNULLREFERENCES products(sku),
qty INTEGERNOTNULLCHECK(qty >0),
unit_cents INTEGERNOTNULLCHECK(unit_cents >=0),PRIMARYKEY(order_id, sku));CREATETABLE session_customer (
customer_id INTEGERNOTNULLREFERENCES customers(customer_id));CREATETABLE order_audit (
order_id INTEGERNOTNULL,
customer_id INTEGERNOTNULL);INSERTINTO customers VALUES(1,'ada@example.com','Ada'),(2,'grace@example.com','Grace'),(3,'ada.obrien@example.com','Ada O''Brien'),(4,'alonzo@example.com','Alonzo');INSERTINTO products VALUES('NB-1','SQL notebook',1200,5),('PEN-2','Gel pen',400,2),('MUG-9','Mug',900,8);INSERTINTO session_customer VALUES(1);INSERTINTO orders VALUES(1,1,'2026-09-01');INSERTINTO order_lines VALUES(1,'NB-1',1,1200);INSERTINTO orders VALUES(2,2,'2026-09-02');INSERTINTO order_lines VALUES(2,'PEN-2',1,400);-- Checkout: Ada (session 1) buys 1 PEN-2 at today's listed_cents.-- Add the stock trigger (after this seed, so history is not replayed).-- BEGIN / INSERT order 3 / INSERT the line / COMMIT.-- SELECT session-scoped lines, PEN-2 stock, and integrity_check.SELECT sku, stock, listed_cents FROM products WHERE sku ='PEN-2';
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Common mistakes to avoid
Storing only listed_cents and calling it revenue.
One products row with repeating sku1, sku2 columns instead of order_lines.
Checkout as two autocommit statements so a crash leaves a header without lines.
Creating stock triggers before loading history, then wondering why seed stock went negative.
SELECT * for the website login, including cost.
Filtering by order_id from the URL without the session.
Pasting emails into SQL text because “it is just this form.”
Shipping a SCAN because the table is small today.
ALTER without user_version or a backup of the old shape.
Lesson review
I can model a shop with keys, CHECKs, and a line-item grain.
I can report with joins and keep sold prices as snapshots.
I can hide cost and enforce a session on customer reads.
I can checkout in one transaction with trigger side effects.
I can bind parameters and read a PK access path.
I can snapshot data and version a schema change.
KNOWLEDGE CHECK
Check the production workshop store
Answer all ten questions, then reopen the runner whose schema, session, checkout, or plan still feels unclear.