Skip to Content
ExamplesLive Inbox

Rust Live Queries

Use KalamLinkClient::live() to receive materialized row snapshots. The SDK merges inserts, updates, and deletes; your async loop handles rows, server errors, transport failures, and shutdown.

Prepare the live-query example

Create app.live_messages with the shared inbox schema. Use the same authenticated identity for the subscriber and the test writes because this is a USER table.

BASH
cargo new live-inboxcd live-inboxcargo add kalam-client --path ../KalamDB/link/kalam-clientcargo add tokio --features macros,rt-multi-thread,signal,timeexport KALAMDB_URL=http://localhost:2900export KALAMDB_USERNAME=your-userexport KALAMDB_PASSWORD=your-password

Run the Rust inbox

Save this as src/main.rs, then run cargo run. It loads a fresh snapshot, prints each current row list, and waits for changes until Ctrl+C. Credentials come from the local terminal; use your application’s authentication strategy for deployment.

RUST
use std::{error::Error, time::Duration};use kalam_client::{AuthProvider, KalamLinkClient, LiveRowsEvent}; #[tokio::main]async fn main() -> Result<(), Box<dyn Error>> {    let client = KalamLinkClient::builder()        .base_url(std::env::var("KALAMDB_URL")            .unwrap_or_else(|_| "http://localhost:2900".into()))        .auth(AuthProvider::basic_auth(            std::env::var("KALAMDB_USERNAME")?,            std::env::var("KALAMDB_PASSWORD")?,        ))        .timeout(Duration::from_secs(30))        .build()?;     // Disconnect even when connect/subscription setup or the loop fails.    let result = watch(&client).await;    client.disconnect().await;    result} async fn watch(client: &KalamLinkClient) -> Result<(), Box<dyn Error>> {    client.connect().await?;    let mut live = client.live(        "SELECT id, room, body FROM app.live_messages WHERE room = 'main'",    ).await?;     println!("Loading inbox…");    let mut received_rows = false;    let first_rows_deadline = tokio::time::sleep(Duration::from_secs(15));    tokio::pin!(first_rows_deadline);    let shutdown = tokio::signal::ctrl_c();    tokio::pin!(shutdown);     let outcome: Result<(), Box<dyn Error>> = loop {        tokio::select! {            signal = &mut shutdown => break signal.map_err(Into::into),            _ = &mut first_rows_deadline, if !received_rows => {                break Err("No initial rows received within 15 seconds".into());            }            event = live.next() => {                match event {                    Some(Ok(LiveRowsEvent::Rows { rows, .. })) => {                        received_rows = true;                        if rows.is_empty() { println!("Inbox is empty"); }                        else { println!("{} messages", rows.len()); }                        for row in rows {                            let id = row.get("id").and_then(|v| v.as_text()).unwrap_or("");                            let body = row.get("body").and_then(|v| v.as_text()).unwrap_or("");                            println!("{id}: {body}");                        }                    }                    Some(Ok(LiveRowsEvent::Error { code, message, .. })) => {                        break Err(format!("Subscription [{code}]: {message}").into());                    }                    Some(Err(error)) => break Err(error.into()),                    None => break Err("Live subscription ended unexpectedly".into()),                }            }        }    };     let close_result = live.close().await;    outcome?;    close_result?;    Ok(())}

Handle each outcome deliberately

Result of live.next()MeaningExample behavior
Some(Ok(LiveRowsEvent::Rows { .. }))Current rows, including an empty snapshotReplace/print the list
Some(Ok(LiveRowsEvent::Error { .. }))Server subscription error with a codeReport it and exit cleanly
Some(Err(error))SDK/transport failureReturn the error after closing
NoneStream endedReport unexpected termination

event? alone does not handle LiveRowsEvent::Error: that variant is inside Ok. Keep both error paths. The first-row deadline applies only to startup; a quiet inbox after loading is normal. live.close() runs after success or loop failure, and disconnect() runs even if subscription setup fails.

This CLI deliberately exits on reported errors. A long-running service can reopen after correcting the cause, using bounded backoff for retryable failures. Do not spin on invalid SQL or denied access, and do not start a second subscription while the first is still owned by another loop.

Verify changes and cleanup

Run the test insert, update, and delete one at a time using the same identity. On a fresh table, expect empty → one message → edited message → empty. The whole current list prints after each change; this is not an append-only event stream.

Press Ctrl+C and confirm the process exits. Temporarily use a missing table to exercise server errors. Stop the server to exercise transport recovery or failure handling. After restarting the example, it obtains a new snapshot with the current rows.

Options and recovery

Use live_with_config(config, LiveRowsConfig { limit: Some(100), ..Default::default() }) to cap the direct materializer’s retained rows. SubscriptionOptions::with_last_rows(100) restricts the starting history; it is separate from retention and snapshot batch_size. The complete inbox above leaves these unset so it can show every matching row.

Keep the SQL in SELECT ... FROM ... WHERE ... form; no joins, aggregates, ORDER BY, or LIMIT. Include the primary key. Sort for presentation if needed. For raw events, use live_events().

The shared connection manages reconnects. Durable resume requires cached state and a checkpoint belonging to the same query and identity. Do not save only the cursor and later expect a full list from an empty materializer. Replay is bounded; follow the WebSocket recovery contract for gaps and expired cursors.

More examples

The repository also includes a self-contained live-inbox smoke example that creates a temporary table, inserts a row, and cleans it up. Its table setup differs from the long-running inbox above.