The original JPetStore boots by executing two SQL files into an in-memory HSQLDB: jpetstore-hsqldb-schema.sql builds thirteen tables, jpetstore-hsqldb-dataload.sql fills them with fish. Every restart, the whole database is rebuilt from scratch and your orders evaporate. Charming for a demo; not what we’re building.

This lesson ports both files into SQLx migrations against a real SQLite file. By the end, cargo run creates and seeds the database on first launch, and the home page proves it with live numbers. Checkpoint: lesson-03.

Migrations, because databases have a history

The original’s schema file describes one moment in time, and rebuilding from it on every boot is what makes that workable. A persistent database needs something else: an ordered record of every change it has ever absorbed, applied exactly once. That’s all migrations are.

SQLx’s tooling is refreshingly small. Files live in migrations/, named 0001_schema.sql, 0002_seed.sql, and one macro call in our db module applies whatever hasn’t run yet:

sqlx::migrate!().run(&pool).await?;

Note what migrate! is: a macro, expanded at compile time, which means the SQL files are read at build and embedded into the binary. There is no migrations/ directory to ship, no classpath resource to forget. The deployment artifact stays exactly one file. Flyway does this job well in the Java world; here it’s a dependency we already had.

Porting the DDL: what survived and what didn’t

Most of the schema crossed over character for character. SQLite is happy with varchar(80), named constraints, and the original’s indexes, and I kept every table and column name identical so the MyBatis mapper SQL we port in later lessons reads the same against both databases. But the port plan promised deliberate fixes, and three of them plus a money decision happen right here, in the DDL:

The sequence table is gone. The original generates order IDs by reading a counter row, incrementing it, and writing it back — application code doing, less safely, what the database does natively. orders.orderid is now integer primary key autoincrement, and lesson 10 gets to delete an entire class of code because of this line.

orders has no creditcard column. The original’s checkout writes the full card number and expiry into the orders table, in the clear. We keep cardtype — a brand name reveals nothing — and the columns for the number and expiry simply do not exist, so no future bug can write them. Lesson 9 covers what real systems do instead.

signon.password grew from varchar(25) to varchar(255). Read the original column size like an archaeologist: 25 characters was plenty, because the password itself went in. Ours stores an argon2 hash, which needs the room.

And every money column became integer cents. decimal(10,2) in SQLite quietly decays into floating point, and floats cannot represent most cent values exactly — 0.1 + 0.2 famously refuses to be 0.3. Java answers this with BigDecimal ceremony; we answer it by storing 1650 where the original stored 16.50 and letting i64 arithmetic be exact. Formatting dollars out of cents is a template concern, and lesson 5 owns it.

Porting the seed data

The dataload file came across nearly verbatim: five categories, sixteen products, twenty-eight items, two suppliers, two accounts. Prices got the cents treatment (16.501650). The twenty-eight identical inventory rows collapsed into one statement, because SQL can say that:

INSERT INTO inventory (itemid, qty) SELECT itemid, 10000 FROM item;

One thing deliberately did not come across. The original seeds signon with ('j2ee','j2ee') — username and password, both in the clear, which you found in lesson 1’s scavenger hunt. Our seed leaves signon empty. The same demo users return in lesson 8, hashed properly, once the app can do the hashing.

Both migration files carry header comments listing every delta and crediting the Apache-2.0 originals. When you port someone’s work, write down what you changed; future-you is the first beneficiary.

A pool, not a DataSource

The connection plumbing is eleven lines, in src/db.rs:

pub async fn pool(url: &str) -> Result<SqlitePool, DbInitError> {
    let options: SqliteConnectOptions =
        url.parse::<SqliteConnectOptions>()?.create_if_missing(true);
    let pool = SqlitePool::connect_with(options).await?;
    sqlx::migrate!().run(&pool).await?;
    Ok(pool)
}

This is the job Spring’s DataSource bean, the JNDI lookup, and the connection-pool XML did in the original, as one function you can read. create_if_missing gives us the HSQLDB convenience — first run conjures the database — without the amnesia. And DbInitError is lesson 2’s pattern at smaller scale: a two-variant enum naming exactly what can fail before the first request, which main handles by printing and exiting, per the startup rule.

The pool reaches the handlers

Axum’s dependency injection is a value, like everything else in this stack. The router registers the pool once:

Router::new()
    .route("/", get(home::home))
    .fallback(not_found)
    .with_state(pool)

and any handler that wants it asks with an extractor argument:

pub async fn home(State(pool): State<SqlitePool>) -> AppResult<Html<String>> {
    let categories: i64 = sqlx::query_scalar("SELECT count(*) FROM category")
        .fetch_one(&pool)
        .await?;
    // ...
}

No @Autowired, no proxy, no container lifecycle: with_state puts a value in, State takes it out, and if the types don’t line up it fails at compile time. The ? on the query is lesson 2 paying off — AppError grew a Database(#[from] sqlx::Error) variant, three lines, and every handler in the app can now propagate query failures to the error page.

These query_scalar calls are checked at runtime only; typo the table name and you find out on page load. That’s MyBatis-level safety, and it’s temporary. Next lesson brings the compile-time-checked queries that are the whole reason SQLx is in the toolbox.

Prove it

cargo run

First launch prints the startup line, creates jpetstore.db next to the project, and the home page at http://localhost:8081/ now reports 16 products across 5 categories, straight from live queries. Then open the file directly:

sqlite3 jpetstore.db 'SELECT itemid, listprice FROM item LIMIT 3;'
sqlite3 jpetstore.db 'SELECT count(*) FROM signon;'

Twenty-eight items priced in cents; zero signon rows, exactly as planned. Delete jpetstore.db and run again — migrations rebuild it. That delete-and-rerun loop is your reset button for the rest of the course.

Build it

File Action What goes in it
Cargo.toml generated cargo add sqlx --features runtime-tokio,sqlite,migrate
migrations/0001_schema.sql copy + write Porting 150 lines of DDL by hand teaches nothing — copy the original’s schema file, then make the four deltas yourself: delete sequence, make orderid autoincrement, drop the card columns, resize password, switch money to integer. Then diff your migration against mine
migrations/0002_seed.sql copy + write Same deal: copy the dataload, then apply the deltas — cents, no signon rows, and try the INSERT..SELECT for inventory
src/db.rs write DbInitError and pool(), shown above
src/web/error.rs modify Add the Database(#[from] sqlx::Error) variant and its status-code arm
src/web/mod.rs modify router(pool) takes the pool, registers it with .with_state(pool)
src/web/home.rs write The State-extracting handler with two query_scalar counts, shown above
src/main.rs modify Declare mod db;, build the pool, pass it to the router
templates/home.html modify Render {{ products }} and {{ categories }}

Done when: the home page reports 16 products across 5 categories, and sqlite3 jpetstore.db 'SELECT count(*) FROM signon;' says 0.

Answer key: lesson-02...lesson-03.

Challenge: count the stock

The home page counts categories and products. Add total inventory to it: sum inventory.qty and render something like “280,000 animals in stock.” You’ll touch the template struct, the handler, and one new query — the full width of what this lesson built. SUM returns NULL on an empty table, so notice what type query_scalar wants to hand you back.

Next lesson the rows become types: modeling the catalog so that an item without a product, or a price that isn’t money, can’t even be constructed.