Build a Live Task Inbox with Rust
Build a Rust task inbox with Axum, SurrealDB, and live Datastar SSE updates.
Introduction
30 Second Summary
Task lists are most useful when new work appears immediately. They earn your trust when every item stays there after you step away.
In this project, you will build a local Live Task Inbox with Rust. Your server-rendered backend saves every task to SurrealDB before Datastar updates the page without a full reload.
What You'll Build
You will type a task in Firefox to watch it appear instantly inside a saved inbox without the page reloading.
By the end of this project, you'll have:
- A server-rendered inbox at http://127.0.0.1:3000 where Axum routes return pages built with Maud.
- A persistent task flow that saves typed records to SurrealDB for retrieval after local restarts.
- A reload-free interaction where Datastar uses server-sent events to patch only the task list.
- Secret Mission: Patch a live task total alongside the updated list in a single Datastar response.
Are there any prerequisites?
You need Fedora Linux 44 with Zed plus temporary internet access for Cargo downloads and the Datastar frontend.
You should already feel comfortable with Rust structs, Result, and async functions.
Before We Start
Before any setup begins, this is your moment to choose who your Live Task Inbox serves. That purpose will keep every later technology tied to a visible workflow.
Prepare the Fedora Toolchain
Your Live Task Inbox eventually uses the Datastar Rust SDK for live patches. It also uses the SurrealDB Rust SDK for persistence.
Both SDKs require Rust 1.89 or newer. Verifying the compiler now prevents confusing build failures once the backend grows.
A verified Cargo toolchain gives every later build a dependable starting point. You will also confirm Zed plus Firefox before scaffolding a runnable project.
In this step, get ready to:
- Verify a Rust toolchain that includes Cargo.
- Confirm the database CLI plus your editor and browser.
- Create a Cargo binary project that prints its generated greeting.
Verify the Rust toolchain
The compiler version controls whether the project dependencies can build. The rustc version check reveals whether your current toolchain meets the minimum.
- Start a terminal from Fedora's application launcher.
- Check the installed Rust compiler by running this command:
rustc --version
What does this command check?
The command prints the active compiler version. Your project needs version 1.89 or newer.
✔️ Rust meets the requirement
Your compiler meets the minimum version. Cargo is confirmed when you scaffold and run the project later in this step.
ⓧ Rust is older than required
An older compiler cannot build the current Datastar Rust SDK or SurrealDB Rust SDK. Update the rustup-managed toolchain before continuing.
- Update Rust by running the commands below:
rustup update
rustc --version
What do these commands do?
The first command updates the rustup-managed toolchain. The second command prints the compiler version after the update.
Continue when the reported compiler version is 1.89 or newer.
Rust still reports an older version?
Restart the terminal so it reloads the Rust tools from ~/.cargo/bin.
If the older compiler remains active, help me find which Rust installation my Fedora terminal is using.
ⓧ Rust is not installed
The official installer sets up rustc plus cargo through rustup. These commands live in ~/.cargo/bin after installation.
The download can take a few minutes while Rust retrieves its components. A quiet terminal during that time is expected.
- Install Rust by running this command:
- Choose the recommended setup option when the installer prompts you.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
What does this command do?
The command downloads the official rustup installer over a secured connection. Rustup installs the compiler plus Cargo into your user account.
- Close the terminal after the installer finishes.
- Start a fresh terminal from Fedora's application launcher.
- Confirm the installed compiler by running this command:
rustc --version
What does this check prove?
A successful version response proves the new terminal can find the rustup-managed compiler. Continue when the reported version is 1.89 or newer.
Rust command still unavailable?
Confirm that the installer finished without a network failure. Restarting the terminal is required before the new path becomes available.
If the command remains unavailable, help me make the rustup tools available in my Fedora terminal.
Prepare SurrealDB, Zed, and Firefox
The SurrealDB CLI starts the local database service used later in the project. Matching version 3.2.4 keeps the server aligned with the Rust SDK.
- Check the installed SurrealDB CLI by running this command:
surreal version
What does this command check?
The command prints the installed SurrealDB CLI version plus its architecture. This project uses the stable 3.2.4 release.
✔️ SurrealDB matches the project
The CLI reports version 3.2.4. Your local database tool is ready for the persistence step.
ⓧ SurrealDB has another version
The stable installer aligns an existing Linux installation with version 3.2.4.
- Update the SurrealDB CLI by running these commands:
curl -sSf https://install.surrealdb.com | sh
surreal version
What do these commands do?
The first command installs the latest stable SurrealDB CLI. The second command prints the resulting version.
Continue when the command reports version 3.2.4.
SurrealDB still reports another version?
Start a fresh terminal after the installer finishes. This lets Fedora reload the command path.
If the version still differs, help me find the active SurrealDB CLI on Fedora.
ⓧ SurrealDB is not installed
The stable Linux installer adds the SurrealDB CLI needed to run the local database server.
- Install the SurrealDB CLI by running these commands:
curl -sSf https://install.surrealdb.com | sh
surreal version
What do these commands do?
The first command installs the stable SurrealDB CLI. The second command confirms the installed version.
Continue when the command reports version 3.2.4.
SurrealDB command unavailable after installation?
Start a fresh terminal so Fedora reloads the installed command path. Check that the installer completed without a network error.
If the command is still unavailable, help me make the SurrealDB CLI available in my Fedora terminal.
Zed gives you a project view for editing the generated files. Firefox gives you the browser needed to test the task inbox in the next step.
- Launch Zed from Fedora's application launcher.
You should see Zed's welcome page or an editor window. This confirms the editor starts normally.
Zed does not open?
Close any stalled Zed window before trying again. Check Fedora for a pending application update if the editor exits immediately.
If Zed still fails to start, help me troubleshoot Zed on Fedora.
- Launch Firefox from Fedora's application launcher.
- Choose the tab that matches what happens.
✔️ Firefox opened
Your browser is ready. You can use it to inspect the Live Task Inbox once the Axum server starts.
ⓧ Firefox is unavailable
Installing a system package may require your administrator password. Fedora uses that approval to add Firefox from its package repository.
- Install Firefox by running this command:
- Approve the package transaction if the terminal asks.
sudo dnf install firefox
What does this command do?
The dnf package manager installs the Fedora package named firefox.
- Launch Firefox from Fedora's application launcher after installation.
You should see a Firefox window ready to load a local web address.
Firefox installation failed?
Confirm that Fedora has internet access for the package download. Rerun the command after any temporary repository error clears.
If the package still fails to install, help me troubleshoot the Firefox package on Fedora.
Scaffold and run the Cargo project
A Cargo binary scaffold creates the package manifest plus a small Rust program. Running that generated program proves the compiler and Cargo work together.
- Return to your Fedora terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The command places the terminal in your Desktop directory. Cargo creates the new project folder there.
- Create the live-task-inbox binary project by running this command:
cargo new live-task-inbox --bin
What does this command create?
Cargo creates a binary package named live-task-inbox. The scaffold contains Cargo.toml plus src/main.rs.
The terminal confirms that Cargo created the binary package. Your first project files now exist on the Desktop.
Cargo could not create the project?
Check that the terminal is on your Desktop. Remove any incomplete live-task-inbox folder before retrying the scaffold command.
If Cargo reports another problem, help me troubleshoot cargo new on Fedora.
- Enter the new project directory by running this command:
cd live-task-inbox
What does this command do?
The terminal moves into the live-task-inbox directory. Commands you run now use this project's manifest.
- Run the generated Rust program by running this command:
cargo run
What does this command do?
Cargo compiles the generated source code. It then starts the resulting binary.
That first Rust binary is working. The terminal prints Hello, world! after the build finishes.
- Switch back to Zed.
- Press Ctrl+O to open a folder picker.
- Choose the live-task-inbox folder from your Desktop.
Zed's project panel should list Cargo.toml plus the src folder. This confirms the correct project is open.
- Select Cargo.toml in Zed's project panel.
- Find this package edition line:
edition = "2024"
What does this line control?
The edition selects the Rust language edition used by the package. Cargo generated the project with edition 2024.
- Add the Rust version line immediately below the edition line:
edition = "2024"
rust-version = "1.89"
Why record the Rust version?
The rust-version field records the project's minimum supported compiler. Cargo can flag an incompatible toolchain before dependency compilation begins.
- Save Cargo.toml by pressing Ctrl+S.
Before you rerun the project, what greeting do you expect the generated binary to print?
- Return to the terminal from earlier.
- Verify the saved project by running this command:
cargo run
What does this final run prove?
Cargo reads the saved manifest before compiling the binary. A successful run confirms the project accepts the minimum Rust version declaration.
You should see Hello, world! in the terminal. You are now inside the runnable live-task-inbox project directory.
Do you see a Cargo error?
Confirm that rust-version = "1.89" sits inside the [package] section. Check that your terminal path ends with live-task-inbox.
If the program does not print the greeting, help me debug cargo run in my Live Task Inbox project.
✔️ Awesome, I've got everything!
Your project is open in Zed. Double check that Cargo.toml is saved before moving on.
ⓧ I'd like to double check the full code
Your complete Cargo.toml should match this file:
[package]
name = "live-task-inbox"
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
[dependencies]
What should this file contain?
The package section names the project plus its edition and minimum Rust version. The dependency section remains empty at this stage.
Your complete src/main.rs should match the generated program:
fn main() {
println!("Hello, world!");
}
What should this program do?
The generated main function prints one greeting. This is the binary that cargo run builds and starts.
Your compiler, database CLI, editor, browser, and Cargo project are ready. Next up, you will replace the generated greeting with a server-rendered Live Task Inbox.
Render the First Task Inbox
Your Rust toolchain has already compiled the generated Cargo program successfully.
The task inbox now needs a visible browser response. A page built with server-side HTML rendering proves the HTTP and template layers work before persistence or live updates enter the request flow.
Axum maps browser requests to Rust handlers. Maud turns Rust data into HTML.
In this step, get ready to:
- Pin the Axum, Maud, and Tokio dependencies.
- Render a page shell with a heading and an empty task area.
- Route GET / to the page and verify it in Firefox.
Add the web dependencies
Tokio provides the async runtime that keeps the server ready for requests. Exact dependency pins keep the code aligned with the APIs used throughout this project.
- Switch back to Cargo.toml in Zed.
- Find the existing [dependencies] section.
- Add the three dependency entries directly below it by copying this code:
axum = "=0.8.9"
maud = { version = "=0.27.0", features = ["axum"] }
tokio = { version = "=1.53.1", features = ["macros", "net", "rt-multi-thread"] }
What do these dependencies provide?
- Axum 0.8.9 provides the router and server integration.
- Maud 0.27.0 renders HTML through Rust macros.
- Maud's axum feature allows Markup to become an Axum response.
- Tokio 1.53.1 provides the macros, networking support, and multi-threaded runtime required by the server.
- Save Cargo.toml in Zed.
The first build can take a few minutes while Cargo downloads and compiles the new crates. The terminal may stay busy while that work completes.
- Download the dependencies and rebuild the existing program by running this command:
cargo run
What does this command prove?
Cargo reads Cargo.toml to resolve the exact versions. It then compiles the existing binary with those dependencies available.
Your terminal prints Hello, world! again. That successful build confirms the web dependencies work with your Rust toolchain.
Did the dependency build fail?
Confirm each dependency line matches the snippet exactly. A missing quote or brace makes Cargo.toml invalid.
Confirm your internet connection is active because Cargo needs to download these crates on the first build.
help me diagnose the Cargo dependency build using my terminal output
Render the page shell
The page() helper owns the browser-facing markup. Maud checks the template as Rust code during compilation.
- Switch back to src/main.rs in Zed.
- Place these imports and the page() helper directly above the existing fn main() block:
use axum::{routing::get, Router};
use maud::{html, Markup, DOCTYPE};
use tokio::net::TcpListener;
fn page() -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head { title { "Live Task Inbox" } }
body {
main {
h1 { "Live Task Inbox" }
p { "No tasks yet. Add one soon." }
}
}
}
}
}
What does this code do?
- The imports bring the router, template tools, and network listener into scope.
- The html! macro builds a complete HTML document as Markup.
- The DOCTYPE value tells the browser to interpret the response as modern HTML.
- The page includes the inbox heading and a message that makes the empty state visible.
- Save src/main.rs in Zed.
- Confirm the Maud template compiles by running this command:
cargo run
What does this check prove?
The existing entry point still runs the generated message. A successful build proves Rust accepts the new imports and Maud template.
The terminal prints Hello, world! again. You may also see warnings about imports that the next substep connects to the server.
Is the Maud template failing to compile?
Check the braces around html! and page(). Each HTML element must remain inside the macro's outer braces.
Confirm the function sits above the existing fn main() block.
help me fix the Maud template in src/main.rs
Connect the route and inspect it
The router needs a handler for the browser's request. A TCP listener gives that router a local address where Firefox can reach it.
- Find the existing fn main() block at the bottom of src/main.rs.
- Replace that entire block with the handler and async entry point below:
async fn index() -> Markup {
page()
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new().route("/", get(index));
let listener = TcpListener::bind("127.0.0.1:3000").await?;
println!("Live Task Inbox: http://127.0.0.1:3000");
axum::serve(listener, app).await?;
Ok(())
}
How does the request reach the page?
- The index() handler returns the markup produced by page().
- The Router sends requests for / to index().
- The TcpListener binds the application to 127.0.0.1:3000.
- The axum::serve call keeps the application running while it handles requests.
- Save src/main.rs in Zed.
Before you start the server, what do you expect Firefox to show when it requests the root route?
- Start the Axum server by running this command:
cargo run
What does this command start?
Cargo compiles the completed server and starts its async entry point. The process stays active so Axum can respond to browser requests.
The terminal prints Live Task Inbox: http://127.0.0.1:3000. Keep this process running while you test the page.
- Press the Super key to open Fedora's application search.
- Type Firefox into the search box.
- Select Firefox from the results.
- Enter http://127.0.0.1:3000 in the address bar.
- Press Enter to request the page.
You should see the Live Task Inbox heading above the empty message No tasks yet. Add one soon. Your first server-rendered inbox is now responding in the browser.
Do you see a browser error?
Confirm the terminal running cargo run is still active. Closing that process stops the local server.
Check that the browser address uses port 3000 and starts with http://.
help me diagnose why Firefox cannot reach my local Axum server
✔️ Awesome, I've got everything!
Great. Save both files and keep the Axum server running for the next step.
ⓧ I'd like to double check the full code
Your complete Cargo.toml should look exactly like this.
[package]
name = "live-task-inbox"
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
[dependencies]
axum = "=0.8.9"
maud = { version = "=0.27.0", features = ["axum"] }
tokio = { version = "=1.53.1", features = ["macros", "net", "rt-multi-thread"] }
Your complete src/main.rs should look exactly like this.
use axum::{routing::get, Router};
use maud::{html, Markup, DOCTYPE};
use tokio::net::TcpListener;
fn page() -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head { title { "Live Task Inbox" } }
body {
main {
h1 { "Live Task Inbox" }
p { "No tasks yet. Add one soon." }
}
}
}
}
}
async fn index() -> Markup {
page()
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new().route("/", get(index));
let listener = TcpListener::bind("127.0.0.1:3000").await?;
println!("Live Task Inbox: http://127.0.0.1:3000");
axum::serve(listener, app).await?;
Ok(())
}
Your Axum route now serves a Maud-rendered task inbox from a local address. Next, you will connect that visible interface to persistent task records.
Persist Tasks with Forms
Your inbox already proves that Axum routes can return pages rendered with Maud. Tasks still disappear because the application has no persistent storage.
This step connects the inbox to a local database. A traditional HTML form then exposes the interaction cost of replacing the current document after every submission.
In this step, get ready to:
- Run persistent local storage for the task records.
- Connect typed Rust records to shared application state.
- Submit tasks through a URL-encoded form.
Start persistent SurrealDB storage
SurrealDB stores each task as a record. The surrealkv://task-inbox.db path keeps those records on disk across process restarts.
- Select Cargo.toml in Zed's project tree.
- Replace the file contents with this dependency configuration:
[package]
name = "live-task-inbox"
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
[dependencies]
axum = "=0.8.9"
maud = { version = "=0.27.0", features = ["axum"] }
serde = { version = "=1.0.229", features = ["derive"] }
surrealdb = "=3.2.4"
tokio = { version = "=1.53.1", features = ["macros", "net", "rt-multi-thread"] }
What Do These Dependencies Add?
- Cargo uses the exact version pins to keep the project reproducible.
- Serde derives the deserialization logic needed by the form extractor.
- The SurrealDB Rust SDK provides the typed database client used by the application.
- Save Cargo.toml.
- Return to the terminal from the previous step.
- Stop the existing server by pressing Ctrl+C if the terminal is still occupied.
- Start the current application after Cargo resolves the new dependencies by running:
cargo run
The first build can take several minutes while Cargo downloads the database SDK. You will see Live Task Inbox: http://127.0.0.1:3000 when the existing application starts successfully.
- Stop the application by pressing Ctrl+C after the startup message appears.
Dependency Build Failed?
Confirm that Cargo.toml matches the dependency configuration above. A missing quote or brace prevents Cargo from reading the file.
Check that your terminal is using Rust 1.89 or newer.
Help me diagnose the Cargo dependency build.
Keep Local Credentials Local
The root user and secret password are development credentials for this local server. A network-exposed database needs production authentication.
- Create a second terminal session in Zed.
- Start the persistent database from the live-task-inbox folder by running:
surreal start --user root --pass secret surrealkv://task-inbox.db
You will see Started web server on 127.0.0.1:8000 when SurrealDB is ready. The server keeps this terminal occupied while it accepts database connections.
- Leave the SurrealDB terminal running.
Database Did Not Start?
Confirm that the terminal prompt points to the live-task-inbox folder. This location determines where SurrealDB creates task-inbox.db.
Check whether another local process already occupies port 8000.
Help me start the local SurrealDB server.
Connect Axum to typed records
The application needs one shared database connection for every request. Axum's state extractor gives each handler access to that client without creating a new connection.
- Select src/main.rs in Zed's project tree.
- Replace the three import lines at the top with these imports:
use axum::{
extract::{Form, State},
routing::{get, post},
Router,
};
use maud::{html, Markup, DOCTYPE};
use serde::Deserialize;
use std::sync::Arc;
use surrealdb::{
engine::remote::ws::{Client, Ws},
opt::auth::Root,
types::{RecordId, SurrealValue},
Surreal,
};
use tokio::net::TcpListener;
What Do These Imports Prepare?
- Form and State extract typed values from incoming requests.
- Client and Ws describe a SurrealDB client using a WebSocket connection.
- Arc lets handlers share ownership of one database client.
- RecordId and SurrealValue support typed database records.
- Save src/main.rs.
- Confirm the new imports compile by running:
cargo run
You will see the existing Live Task Inbox startup message. This confirms that every imported API is available from the pinned dependencies.
- Stop the application by pressing Ctrl+C.
Seeing an Import Error?
Compare the import block character by character. Pay close attention to the nested braces around Client and Ws.
Help me fix the Rust imports.
- Add these shared-state and record models immediately below the imports:
#[derive(Clone)]
struct AppState {
db: Arc<Surreal<Client>>,
}
#[derive(Debug, SurrealValue)]
struct Task {
#[allow(dead_code)]
id: RecordId,
title: String,
}
#[derive(Debug, Deserialize, SurrealValue)]
struct NewTask {
title: String,
}
How Do The Types Fit Together?
- AppState holds the shared database client used by every handler.
- Task represents a complete record returned by SurrealDB.
- NewTask represents the form data needed to create a record.
- Deserialize converts the URL-encoded title into NewTask.
- Save src/main.rs.
- Confirm the models compile by running:
cargo run
You will see the existing application start again. This proves the Rust models satisfy the traits required by the database SDK.
- Stop the application by pressing Ctrl+C.
Model Derive Failed?
Confirm that Task derives SurrealValue. Confirm that NewTask derives both Deserialize and SurrealValue.
Help me fix the typed task models.
- Add this creation handler immediately below the existing index() function:
async fn create_task(State(state): State<AppState>, Form(task): Form<NewTask>) {
let _: Option<Task> = state.db.create("task").content(task).await.unwrap();
}
What Does The Creation Handler Do?
- State(state) extracts the shared database connection.
- Form(task) decodes the submitted title field into NewTask.
- create("task") inserts the typed record into the task table.
- Save src/main.rs.
- Confirm the new handler compiles by running:
cargo run
You will see the existing application start. The new handler now compiles even though the router does not expose it yet.
- Stop the application by pressing Ctrl+C.
Form Extractor Failed To Compile?
Keep Form(task) after State(state) in the handler parameters. The form consumes the request body.
Help me fix the task creation handler.
- Find the existing main() function at the bottom of src/main.rs.
- Replace the entire function with this database-backed startup logic:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Surreal::new::<Ws>("127.0.0.1:8000").await?;
db.signin(Root {
username: "root".to_owned(),
password: "secret".to_owned(),
}).await?;
db.use_ns("main").use_db("main").await?;
db.query("DEFINE TABLE IF NOT EXISTS task SCHEMALESS; DEFINE FIELD IF NOT EXISTS title ON TABLE task TYPE string;").await?.check()?;
let app = Router::new()
.route("/", get(index))
.route("/tasks", post(create_task))
.with_state(AppState { db: Arc::new(db) });
let listener = TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
How Does Startup Prepare Persistence?
- Surreal::new::<Ws>() connects to the local database server on port 8000.
- signin() authenticates the local root user.
- use_ns("main").use_db("main") selects the namespace and database used by the inbox.
- db.query() defines the schemaless task table with a string title field.
- with_state() shares the connected client with both routes.
- Save src/main.rs.
- Start the database-backed application by running:
cargo run
- Refresh http://127.0.0.1:3000 in Firefox.
You will still see the Live Task Inbox heading. The page proves that startup connected successfully because the router is created only after authentication and schema setup complete.
- Stop the application by pressing Ctrl+C.
Application Exits During Startup?
Confirm that the SurrealDB terminal is still running on port 8000. Confirm that its local credentials match the values in main().
Help me debug the database connection.
- Add this list renderer immediately above the existing page() function:
fn task_list(tasks: &[Task]) -> Markup {
html! {
ul #task-list {
@if tasks.is_empty() {
li { "No tasks yet. Add one above." }
} @else {
@for task in tasks {
li { (task.title) }
}
}
}
}
}
How Does The List Renderer Work?
- task_list() receives a borrowed slice of typed task records.
- #task-list gives the list a stable element ID.
- tasks.is_empty() renders guidance when the database has no records.
- @for renders one list item for each saved title.
- Save src/main.rs.
- Confirm the typed renderer compiles by running:
cargo run
The server stays running after compilation. This proves that Maud can render the title from each typed Task record.
- Stop the application by pressing Ctrl+C.
Task List Does Not Compile?
Confirm that the function accepts &[Task]. Check that the loop uses the field name title.
Help me fix the Maud task list.
Submit tasks through a form
A normal browser form sends its fields as a URL-encoded request body. The updated page handler reads all saved tasks whenever Firefox requests /.
- Find the existing page() and index() functions in src/main.rs.
- Replace both functions with this dynamic page and database query:
fn page(tasks: &[Task]) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head { title { "Live Task Inbox" } }
body {
main {
h1 { "Live Task Inbox" }
form method="post" action="/tasks" {
label for="task-title" { "New task" }
input #task-title name="title";
button type="submit" { "Add task" }
}
(task_list(tasks))
}
}
}
}
}
async fn index(State(state): State<AppState>) -> Markup {
let tasks: Vec<Task> = state.db.select("task").await.unwrap();
page(&tasks)
}
How Does The Page Use Stored Tasks?
- page() receives the records selected by the request handler.
- method="post" sends the form body to /tasks.
- name="title" matches the field in NewTask.
- state.db.select("task") loads every saved record before rendering the page.
- Save src/main.rs.
- Start the completed form version by running:
cargo run
- Return to http://127.0.0.1:3000 in Firefox.
You will see the Live Task Inbox heading above a New task field and an Add task button. The empty list says that you can add a task above.
Form Missing From The Page?
Confirm that Firefox is loading port 3000. Check that the running process was started after you saved src/main.rs.
Help me find the missing task form.
- Enter Ship the Axum backend in the New task field.
Before you submit the form, do you think Firefox will update only the list or replace the current document?
- Click Add task.
Firefox replaces the page with the empty response from POST /tasks. This blank document is the intended shortfall of the traditional form flow.
- Return to http://127.0.0.1:3000.
You will see Ship the Axum backend in the list. Returning to the GET route reloads the entire page before the saved task becomes visible.
- Enter Patch the task list in the New task field.
- Click Add task.
- Return to http://127.0.0.1:3000.
You will see both task titles in the list. The database now has two records even though each submission replaces the page.
What Did The Reload Prove?
The form proves that browser input reaches an Axum handler as a typed NewTask value. The handler stores that value before the next GET request reads it back.
The blank POST response also exposes the interaction cost. A useful task update should preserve the page around the list.
- Stop the Axum application by pressing Ctrl+C in its terminal.
- Stop SurrealDB by pressing Ctrl+C in the database terminal.
Before you restart both processes, do you expect the two task records to survive?
- Restart the persistent database in its terminal by running:
surreal start --user root --pass secret surrealkv://task-inbox.db
You will see Started web server on 127.0.0.1:8000 again when the same on-disk database is ready.
- Restart the Axum application in its terminal by running:
cargo run
- Refresh http://127.0.0.1:3000 in Firefox.
You will see both saved task titles after the restart. That is the hard part done: your inbox now persists typed records across application and database restarts.
Tasks Missing After The Restart?
Confirm that SurrealDB restarted from the live-task-inbox folder. Starting it elsewhere creates or opens a different relative task-inbox.db path.
Start SurrealDB before the Axum application. The application requires the database connection during startup.
Help me recover the persisted tasks.
✔️ Awesome, I've got everything!
Great. Double check that both files are saved while the two persisted tasks remain visible in Firefox.
ⓧ I'd like to double check the full code
Compare your complete Cargo.toml with this reference:
[package]
name = "live-task-inbox"
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
[dependencies]
axum = "=0.8.9"
maud = { version = "=0.27.0", features = ["axum"] }
serde = { version = "=1.0.229", features = ["derive"] }
surrealdb = "=3.2.4"
tokio = { version = "=1.53.1", features = ["macros", "net", "rt-multi-thread"] }
Compare your complete src/main.rs with this reference:
use axum::{
extract::{Form, State},
routing::{get, post},
Router,
};
use maud::{html, Markup, DOCTYPE};
use serde::Deserialize;
use std::sync::Arc;
use surrealdb::{
engine::remote::ws::{Client, Ws},
opt::auth::Root,
types::{RecordId, SurrealValue},
Surreal,
};
use tokio::net::TcpListener;
#[derive(Clone)]
struct AppState {
db: Arc<Surreal<Client>>,
}
#[derive(Debug, SurrealValue)]
struct Task {
#[allow(dead_code)]
id: RecordId,
title: String,
}
#[derive(Debug, Deserialize, SurrealValue)]
struct NewTask {
title: String,
}
fn task_list(tasks: &[Task]) -> Markup {
html! {
ul #task-list {
@if tasks.is_empty() {
li { "No tasks yet. Add one above." }
} @else {
@for task in tasks {
li { (task.title) }
}
}
}
}
}
fn page(tasks: &[Task]) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head { title { "Live Task Inbox" } }
body {
main {
h1 { "Live Task Inbox" }
form method="post" action="/tasks" {
label for="task-title" { "New task" }
input #task-title name="title";
button type="submit" { "Add task" }
}
(task_list(tasks))
}
}
}
}
}
async fn index(State(state): State<AppState>) -> Markup {
let tasks: Vec<Task> = state.db.select("task").await.unwrap();
page(&tasks)
}
async fn create_task(State(state): State<AppState>, Form(task): Form<NewTask>) {
let _: Option<Task> = state.db.create("task").content(task).await.unwrap();
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Surreal::new::<Ws>("127.0.0.1:8000").await?;
db.signin(Root {
username: "root".to_owned(),
password: "secret".to_owned(),
}).await?;
db.use_ns("main").use_db("main").await?;
db.query("DEFINE TABLE IF NOT EXISTS task SCHEMALESS; DEFINE FIELD IF NOT EXISTS title ON TABLE task TYPE string;").await?.check()?;
let app = Router::new()
.route("/", get(index))
.route("/tasks", post(create_task))
.with_state(AppState { db: Arc::new(db) });
let listener = TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Your inbox now stores typed tasks in SurrealDB across restarts. Next up, you will replace the document reload with a targeted Datastar update.
Patch Tasks with Datastar
Your inbox now stores tasks in SurrealDB. However, each submission reloads the whole page even though only the task list changed.
This step uses Datastar signals to send the task title to Axum. The backend responds with a server-sent event that patches the stable #task-list element.
In this step, get ready to:
- Add the Datastar patch dependencies.
- Bind the task composer to a browser signal.
- Return a task-list patch through an SSE response.
Install the patch dependencies
The Datastar Rust SDK builds the patch event. The futures utility crate turns that event into the stream expected by Axum's SSE response.
- Return to the Axum terminal from earlier.
- Press Ctrl+C if the previous application process is still running.
- Replace the contents of Cargo.toml with this dependency configuration:
[package]
name = "live-task-inbox"
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
[dependencies]
axum = "=0.8.9"
datastar = { version = "=0.4.0", features = ["axum"] }
futures-util = "=0.3.34"
maud = { version = "=0.27.0", features = ["axum"] }
serde = { version = "=1.0.229", features = ["derive"] }
surrealdb = "=3.2.4"
tokio = { version = "=1.53.1", features = ["macros", "net", "rt-multi-thread"] }
What do these dependencies provide?
- The Datastar Rust SDK at version 0.4.0 supplies the signal extractor and element-patch builder.
- The axum feature connects those types to Axum requests and responses.
- The futures utility crate at version 0.3.34 creates a stream containing the single patch event.
- Save Cargo.toml.
The first build after adding these crates can take longer while Cargo downloads and compiles them.
- Download the dependencies and start the current application by running:
cargo run
What does this check prove?
Cargo resolves the pinned packages before compiling the application. A running binary confirms that the new dependencies support your Rust toolchain.
You should see Cargo finish compiling the project and start the application process.
Dependencies fail to compile?
Check that Cargo.toml matches the configuration above. A missing feature or misplaced quote can prevent dependency resolution.
A compiler message about the supported Rust release usually means your active toolchain is older than the project requirement.
Help me diagnose the dependency build failure.
- Stop the application by pressing Ctrl+C.
Bind the task composer to browser signals
A Datastar signal holds the current input value in the browser. The click action sends that signal to the existing /tasks route without submitting a traditional HTML form.
- In src/main.rs, update the Maud import so it matches this line:
use maud::{html, Markup, PreEscaped, DOCTYPE};
Why add PreEscaped?
The PreEscaped type lets the page include the small static fragment containing Datastar's colon-based attributes. Maud continues to render the dynamic task data safely.
- Add the first fragment of the CSS constant below the import section:
const CSS: &str = r#"
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #111827;
color: #f9fafb;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: start center;
}
main {
width: min(42rem, calc(100% - 2rem));
margin-top: 5rem;
}
.lede, .empty {
color: #9ca3af;
}
.composer-row {
display: flex;
gap: 0.75rem;
margin: 1.5rem 0;
}
What does this first fragment style?
This part establishes the dark page theme. It also constrains the inbox width and arranges the composer controls in one row.
- Continue the same raw string immediately after the previous fragment with this final fragment:
input, button {
border: 1px solid #374151;
border-radius: 0.6rem;
padding: 0.8rem 1rem;
font: inherit;
}
input {
flex: 1;
background: #1f2937;
color: #f9fafb;
}
button {
background: #22c55e;
color: #052e16;
cursor: pointer;
font-weight: 700;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 0.65rem 0;
padding: 1rem;
border: 1px solid #374151;
border-radius: 0.6rem;
background: #1f2937;
}
"#;
What does this final fragment style?
This part gives the input and button clear interactive states. Each task becomes a separate card inside the list.
- Replace the existing page() function with this Datastar-enabled page:
fn page(tasks: &[Task]) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "Live Task Inbox" }
script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.3/bundles/datastar.js" {}
style { (PreEscaped(CSS)) }
}
body {
main {
h1 { "Live Task Inbox" }
p .lede { "Axum routes. Maud markup. SurrealDB records. Datastar patches." }
(PreEscaped(r#"
<div class="composer">
<label for="task-title">New task</label>
<div class="composer-row">
<input id="task-title" data-bind:title placeholder="Ship the Axum backend">
<button type="button" data-on:click="@post('/tasks')">Add task</button>
</div>
</div>
"#))
(task_list(tasks))
}
}
}
}
}
How does the browser interaction work?
- The module script loads Datastar frontend version 1.0.3 from its documented jsDelivr URL.
- The data-bind:title attribute stores the input value in a signal named title.
- The data-on:click action posts the current signals to /tasks.
- The stable #task-list ID gives the later patch a precise DOM target.
- Replace the existing task_list() function with this updated renderer:
fn task_list(tasks: &[Task]) -> Markup {
html! {
ul #task-list {
@if tasks.is_empty() {
li .empty { "No tasks yet. Add one above." }
} @else {
@for task in tasks {
li { (task.title.as_str()) }
}
}
}
}
}
Why keep the list ID stable?
Datastar matches returned elements to existing DOM elements by ID. Returning #task-list lets the browser morph that list while preserving the rest of the page.
- Save src/main.rs.
- Start the updated page by running:
cargo run
What does this run check?
This run checks that the new Maud page and CSS compile together. It also serves the Datastar-enabled composer to Firefox.
- Refresh http://127.0.0.1:3000 in Firefox.
You should see a dark Live Task Inbox with a task input and a green Add task button. Existing database tasks should appear as separate cards.
Page missing its new layout?
Confirm that both CSS fragments sit inside one raw string. The final fragment must include the closing raw-string marker.
Check that the page uses PreEscaped(CSS) inside the style element.
Help me debug the Datastar page layout.
- Return to the Axum terminal.
- Stop the application by pressing Ctrl+C.
Return the task-list patch
The browser now sends JSON signals instead of URL-encoded form data. The handler needs a typed signal extractor and an SSE response containing the refreshed list markup.
- Replace the import section at the top of src/main.rs with these imports:
use axum::{
extract::State,
http::StatusCode,
response::sse::{Event, Sse},
routing::{get, post},
Router,
};
use datastar::{axum::ReadSignals, prelude::PatchElements};
use futures_util::stream::{self, Stream};
use maud::{html, Markup, PreEscaped, DOCTYPE};
use serde::Deserialize;
use std::{convert::Infallible, fmt::Display, sync::Arc};
use surrealdb::{
engine::remote::ws::{Client, Ws},
opt::auth::Root,
types::{RecordId, SurrealValue},
Surreal,
};
use tokio::net::TcpListener;
What do the new imports connect?
The Datastar imports connect request signals to element patches. The Axum and futures imports provide the SSE response and its one-event stream.
- Replace the existing state and model definitions with this typed model section:
#[derive(Clone)]
struct AppState {
db: Arc<Surreal<Client>>,
}
#[derive(Debug, SurrealValue)]
struct Task {
#[allow(dead_code)]
id: RecordId,
title: String,
}
#[derive(Debug, SurrealValue)]
struct NewTask {
title: String,
}
#[derive(Debug, Deserialize)]
struct CreateTaskSignals {
title: String,
}
Why use two input models?
The CreateTaskSignals struct describes JSON arriving from the browser. The NewTask struct describes the record sent to SurrealDB.
- Add this web result type and error adapter immediately below CreateTaskSignals:
type WebResult<T> = Result<T, (StatusCode, String)>;
fn web_error(error: impl Display) -> (StatusCode, String) {
eprintln!("{error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"The backend could not complete the request.".to_owned(),
)
}
How are backend errors handled?
The WebResult alias gives handlers one shared error shape. The adapter logs the detailed failure while returning a safe response message.
- Replace the existing index() handler with this result-returning version:
async fn index(State(state): State<AppState>) -> WebResult<Markup> {
let tasks: Vec<Task> = state.db.select("task").await.map_err(web_error)?;
Ok(page(&tasks))
}
What changes in the page handler?
The database query now converts failures through web_error instead of panicking. A successful query still renders the complete initial page.
- Replace the existing create_task() handler with this SSE handler:
async fn create_task(
State(state): State<AppState>,
ReadSignals(signals): ReadSignals<CreateTaskSignals>,
) -> WebResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
let title = signals.title.trim();
if title.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Task title cannot be empty.".to_owned(),
));
}
let _: Option<Task> = state
.db
.create("task")
.content(NewTask {
title: title.to_owned(),
})
.await
.map_err(web_error)?;
let tasks: Vec<Task> = state.db.select("task").await.map_err(web_error)?;
let event = PatchElements::new(task_list(&tasks).into_string())
.write_as_axum_sse_event();
let events = stream::once(async move { Ok::<Event, Infallible>(event) });
Ok(Sse::new(events))
}
How does the patch response flow?
- The ReadSignals extractor deserializes the browser's JSON body into CreateTaskSignals.
- The handler trims the title before rejecting an empty value.
- The database stores a NewTask record before returning the current task collection.
- The PatchElements event carries only the rendered #task-list element.
- The one-item stream lets Sse return that patch as an event-stream response.
- Replace the first part of main() with this database setup fragment:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Surreal::new::<Ws>("127.0.0.1:8000").await?;
db.signin(Root {
username: "root".to_owned(),
password: "secret".to_owned(),
})
.await?;
db.use_ns("main").use_db("main").await?;
db.query(
r#"
DEFINE TABLE IF NOT EXISTS task SCHEMALESS;
DEFINE FIELD IF NOT EXISTS title ON TABLE task TYPE string;
"#,
)
.await?
.check()?;
What does this setup preserve?
The application still connects to the running local database with the existing development credentials. It also selects the same namespace and database before confirming the task schema.
- Continue main() immediately after the database setup with this server fragment:
let state = AppState { db: Arc::new(db) };
let app = Router::new()
.route("/", get(index))
.route("/tasks", post(create_task))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:3000").await?;
println!("Live Task Inbox: http://127.0.0.1:3000");
axum::serve(listener, app).await?;
Ok(())
}
What does the server fragment do?
The router shares the database state with both handlers. The startup message gives you the exact local address before Axum begins serving requests.
- Save src/main.rs.
Pause for a prediction: decide whether adding the next task will replace the page or only change the list.
- Start the completed application by running:
cargo run
What should the terminal confirm?
The terminal should print Live Task Inbox: http://127.0.0.1:3000 after the database connection and schema setup succeed.
- Refresh http://127.0.0.1:3000 in Firefox.
- Open Firefox's developer tools.
- Select the Network panel.
- Enter Patch the task list in the task input.
- Click Add task.
- Select the POST /tasks request in the Network panel.
You should see the new task appear inside #task-list without a page reload. The selected response should use text/event-stream.
That completes the interaction loop. Your backend now stores the task and patches only the part of the page that changed.
Task does not appear?
Confirm that the input uses data-bind:title. The Rust signal struct must use the matching title field.
Check that task_list() still renders the stable #task-list ID. Datastar needs that ID to locate the element.
Help me debug the missing Datastar patch.
✔️ Awesome, I've got everything!
Great work. Save both project files before moving on.
ⓧ I'd like to double check the full code
Compare your complete Cargo.toml with this reference.
[package]
name = "live-task-inbox"
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
[dependencies]
axum = "=0.8.9"
datastar = { version = "=0.4.0", features = ["axum"] }
futures-util = "=0.3.34"
maud = { version = "=0.27.0", features = ["axum"] }
serde = { version = "=1.0.229", features = ["derive"] }
surrealdb = "=3.2.4"
tokio = { version = "=1.53.1", features = ["macros", "net", "rt-multi-thread"] }
Compare your complete src/main.rs with this reference.
use axum::{
extract::State,
http::StatusCode,
response::sse::{Event, Sse},
routing::{get, post},
Router,
};
use datastar::{axum::ReadSignals, prelude::PatchElements};
use futures_util::stream::{self, Stream};
use maud::{html, Markup, PreEscaped, DOCTYPE};
use serde::Deserialize;
use std::{convert::Infallible, fmt::Display, sync::Arc};
use surrealdb::{
engine::remote::ws::{Client, Ws},
opt::auth::Root,
types::{RecordId, SurrealValue},
Surreal,
};
use tokio::net::TcpListener;
const CSS: &str = r#"
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #111827;
color: #f9fafb;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: start center;
}
main {
width: min(42rem, calc(100% - 2rem));
margin-top: 5rem;
}
.lede, .empty {
color: #9ca3af;
}
.composer-row {
display: flex;
gap: 0.75rem;
margin: 1.5rem 0;
}
input, button {
border: 1px solid #374151;
border-radius: 0.6rem;
padding: 0.8rem 1rem;
font: inherit;
}
input {
flex: 1;
background: #1f2937;
color: #f9fafb;
}
button {
background: #22c55e;
color: #052e16;
cursor: pointer;
font-weight: 700;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 0.65rem 0;
padding: 1rem;
border: 1px solid #374151;
border-radius: 0.6rem;
background: #1f2937;
}
"#;
#[derive(Clone)]
struct AppState {
db: Arc<Surreal<Client>>,
}
#[derive(Debug, SurrealValue)]
struct Task {
#[allow(dead_code)]
id: RecordId,
title: String,
}
#[derive(Debug, SurrealValue)]
struct NewTask {
title: String,
}
#[derive(Debug, Deserialize)]
struct CreateTaskSignals {
title: String,
}
type WebResult<T> = Result<T, (StatusCode, String)>;
fn web_error(error: impl Display) -> (StatusCode, String) {
eprintln!("{error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"The backend could not complete the request.".to_owned(),
)
}
fn task_list(tasks: &[Task]) -> Markup {
html! {
ul #task-list {
@if tasks.is_empty() {
li .empty { "No tasks yet. Add one above." }
} @else {
@for task in tasks {
li { (task.title.as_str()) }
}
}
}
}
}
fn page(tasks: &[Task]) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "Live Task Inbox" }
script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.3/bundles/datastar.js" {}
style { (PreEscaped(CSS)) }
}
body {
main {
h1 { "Live Task Inbox" }
p .lede { "Axum routes. Maud markup. SurrealDB records. Datastar patches." }
(PreEscaped(r#"
<div class="composer">
<label for="task-title">New task</label>
<div class="composer-row">
<input id="task-title" data-bind:title placeholder="Ship the Axum backend">
<button type="button" data-on:click="@post('/tasks')">Add task</button>
</div>
</div>
"#))
(task_list(tasks))
}
}
}
}
}
async fn index(State(state): State<AppState>) -> WebResult<Markup> {
let tasks: Vec<Task> = state.db.select("task").await.map_err(web_error)?;
Ok(page(&tasks))
}
async fn create_task(
State(state): State<AppState>,
ReadSignals(signals): ReadSignals<CreateTaskSignals>,
) -> WebResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
let title = signals.title.trim();
if title.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Task title cannot be empty.".to_owned(),
));
}
let _: Option<Task> = state
.db
.create("task")
.content(NewTask {
title: title.to_owned(),
})
.await
.map_err(web_error)?;
let tasks: Vec<Task> = state.db.select("task").await.map_err(web_error)?;
let event = PatchElements::new(task_list(&tasks).into_string())
.write_as_axum_sse_event();
let events = stream::once(async move { Ok::<Event, Infallible>(event) });
Ok(Sse::new(events))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Surreal::new::<Ws>("127.0.0.1:8000").await?;
db.signin(Root {
username: "root".to_owned(),
password: "secret".to_owned(),
})
.await?;
db.use_ns("main").use_db("main").await?;
db.query(
r#"
DEFINE TABLE IF NOT EXISTS task SCHEMALESS;
DEFINE FIELD IF NOT EXISTS title ON TABLE task TYPE string;
"#,
)
.await?
.check()?;
let state = AppState { db: Arc::new(db) };
let app = Router::new()
.route("/", get(index))
.route("/tasks", post(create_task))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:3000").await?;
println!("Live Task Inbox: http://127.0.0.1:3000");
axum::serve(listener, app).await?;
Ok(())
}
You have replaced the full-page form reload with a focused Datastar patch. The task now travels through a typed Rust request path before appearing instantly in the inbox.
Secret mission
Patch a Live Task Count
Your task list updates in place, but the page does not show how many tasks it contains. Add a live count that updates beside the list through the same backend event.
Clean Up Your Resources
Clean Up Your Resources
All resources in this project are local, so keeping them does not create ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The live-task-inbox Cargo project folder containing your source files.
- The target/ build directory inside live-task-inbox.
- The task-inbox.db persistent data directory used by SurrealDB.
- The Axum application process listening at 127.0.0.1:3000.
- The SurrealDB server process listening at 127.0.0.1:8000.
Keep everything running
No action needed. Choose this if you want to keep adding tasks or developing the backend.
- Leave the Axum process running at 127.0.0.1:3000.
- Leave the SurrealDB server running at 127.0.0.1:8000.
- Keep the live-task-inbox folder with its source files.
- Keep task-inbox.db so your saved tasks remain available.
Pause - I'll come back to this later
Stop both local processes to free their terminal sessions and ports. Your project files and saved tasks stay on disk.
- Switch back to the terminal running the Axum application from earlier.
- Press Ctrl+C to stop the application.
- Switch back to the terminal running the SurrealDB server from earlier.
- Press Ctrl+C to stop the database server.
That's the pause complete: both terminal prompts are available again. Your source code and persistent task records are ready for your return.
Delete - I don't want to use this again
This choice permanently removes your source code and saved tasks. The cleanup affects only the local resources created for this project.
- Switch back to the terminal running the Axum application from earlier.
- Press Ctrl+C to stop the application.
- Switch back to the terminal running the SurrealDB server from earlier.
- Press Ctrl+C to stop the database server.
Both local services are now stopped. You can remove their files without leaving a process attached to them.
- Return to the terminal that is still inside live-task-inbox.
- Remove your persistent task records by running this command:
rm -rf task-inbox.db
What does this remove?
Removing task-inbox.db deletes every saved task from the local database. Your Rust source remains available until the next cleanup command.
- Delete the entire project from its parent directory by running these commands:
cd ..
rm -rf live-task-inbox
What do these commands remove?
The first line moves your terminal to the directory that contains live-task-inbox. The second line removes the project folder, including Cargo.toml, src/, and target/.
- Verify the cleanup by listing the parent directory with this command:
ls
What should you see?
The command lists the remaining items in the parent directory. You should no longer see live-task-inbox.
Still see the project folder?
Confirm both local processes are stopped. Make sure your terminal is in the directory that contains live-task-inbox before repeating the folder deletion.
Need a second pair of eyes? Help me verify my local project cleanup.
Nice Work!
Nice Work!
Excellent work on your Rust Live Task Inbox. The finished backend stores every task reliably before updating the page without a full reload.
You've learned how to:
- Build an asynchronous Axum server with typed extractors. Share a SurrealDB client across handlers through application state.
- Render database-backed HTML with Maud. Keep typed task records available across application restarts and database restarts.
- Experience the full-page form limitation before replacing it with Datastar signals. Deliver server-sent events that patch only #task-list without reloading the page.
- Secret Mission: Extend one Datastar response to patch both #task-list and #task-count. Keep singular and plural totals accurate with task_count.
Ready to quiz yourself?