Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SQLite API Reference

Projectors and effects each get their own isolated SQLite database file. Modules cannot read each other’s data. This chapter is the complete reference for the SQLite API available inside WASM modules.

In Rust the API is a set of free functions plus Statement (from umari::prelude). In TypeScript it’s the sqlite namespace: import { sqlite } from "@umari/js" (or import * as sqlite from "@umari/js/sqlite").

Mental model

  • One-off statements run against the module’s implicit connection, convenient for individual events.
  • Prepared statements compile the SQL once and reuse it per event; store them on your module’s state.
  • Recoverable errors are constraint violations. Everything else (wrong column name, wrong type, “expected one row but got two”) traps the module: the runtime treats traps as bugs, not business failures.

Every handle() call runs inside an implicit transaction. Don’t reach for BEGIN/COMMIT yourself.

Placeholders differ. Rust examples use numbered placeholders (?1, ?2); the TypeScript SDK uses positional ? with an array of params. Both bind positionally.

Running statements

Execute a single statement

Returns the number of rows affected.

// execute(sql, params) -> Result<usize, SqliteError>
execute(
    "INSERT INTO projects (project_id, user_id, title) VALUES (?1, ?2, ?3)",
    params![project_id, user_id.to_string(), title],
)?;

Execute a batch (DDL)

Run multiple statements separated by semicolons. Use this in init() for CREATE TABLE/CREATE INDEX.

execute_batch(
    "
        CREATE TABLE IF NOT EXISTS widgets (
            widget_id TEXT PRIMARY KEY,
            name TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_widgets_name ON widgets (name);
    ",
)?;

Query one row (exact)

Return exactly one row. Traps if zero or multiple rows match.

let row = query_one(
    "SELECT name, duration_months FROM projects WHERE project_id = ?1",
    params![project_id],
);
let name: String = row.get("name");
let months: i64 = row.get(1);  // by column index

Query at most one row

Return the first match, or nothing. Extra rows are dropped.

// query_row(sql, params) -> Option<Row>
if let Some(row) = query_row(
    "SELECT name FROM users WHERE user_id = ?1",
    params![id],
) {
    let name: String = row.get(0);
}

Query all rows

Use a prepared statement’s query(params) (see below); it returns Vec<Row>.

Last insert rowid

Returns the rowid of the most recent successful INSERT on this connection, or nothing if none has happened.

let rowid: Option<i64> = last_insert_rowid();

Prepared statements

For queries that run on every event, prepare once in init() and reuse. A malformed SQL string traps the module.

struct MyProjector {
    insert_widget: Statement,
    archive_widget: Statement,
}

impl Projector for MyProjector {
    type Query = WidgetEvents;

    fn init() -> anyhow::Result<Self> {
        execute_batch("CREATE TABLE IF NOT EXISTS widgets (...)")?;
        Ok(MyProjector {
            insert_widget: prepare("INSERT INTO widgets (id, name) VALUES (?1, ?2)"),
            archive_widget: prepare("UPDATE widgets SET archived = TRUE WHERE id = ?1"),
        })
    }

    fn handle(&mut self, event: StoredEvent<WidgetEvents>) -> anyhow::Result<()> {
        self.insert_widget.execute(params![id.to_string(), name])?;
        Ok(())
    }
}
MethodReturnsTraps on
execute(params)Result<usize, SqliteError>
query(params)Vec<Row>
query_one(params)Rowzero rows, or more than one row
query_row(params)Option<Row>

Parameters

Pass parameters with the params! macro (positional, for ?1, ?2, …). Each value converts through Into<SqliteValue>.

params![]                          // no params
params![value1, value2, value3]
Rust typeSQLite type
boolInteger (0 or 1)
i8i64, isize, u8u32Integer
f32, f64Real
String, &strText
Vec<u8>Blob
UuidText (canonical hyphenated form)
Option<T>Null when None, otherwise T

Reading rows

Row::get::<T>(column) reads a column by name (&str) or zero-based index (usize). Traps on type mismatch or unknown column.

let name: String = row.get("name");
let count: i64 = row.get(0);
let maybe: Option<String> = row.get("nullable_col");

Row::tuple::<T>() unpacks the first N columns into a tuple by position (up to 8):

let (id, name, months): (String, String, i64) = row.tuple();
Rust typeSQLite value
boolInteger (0/1; other values trap)
StringText
i64Integer
f64Real
Vec<u8>Blob
Option<T>Null → None, otherwise Some(T)

Errors

Only constraint violations are recoverable: they’re the one failure the API surfaces. Everything else traps.

pub enum SqliteError {
    ConstraintViolation(ConstraintViolation),
}

pub struct ConstraintViolation {
    pub kind: ConstraintViolationKind, // Unique, PrimaryKey, NotNull, ForeignKey, Check, Other
    pub message: String,
}

Use a UNIQUE collision to mean “already projected, skip”:

match execute("INSERT INTO widgets (id, name) VALUES (?1, ?2)", params![id, name]) {
    Ok(_) => {}
    Err(SqliteError::ConstraintViolation(v)) if v.kind == ConstraintViolationKind::Unique => {
        // already projected, fine
    }
    Err(err) => return Err(err.into()),
}

Transactions

The runtime wraps every handle() call in a transaction: it begins before the call and commits if the handler succeeds. A failed handler rolls back. You don’t manage transactions manually.

Best practices

  • Use IF NOT EXISTS in DDL so init() is idempotent across module restarts.
  • Store UUIDs as TEXT (SQLite has no UUID type).
  • Store decimals as TEXT to avoid floating-point precision issues.
  • Always pass parameters; never interpolate values into the SQL string.
  • Prepare per-event statements in init(); use the one-off helpers for individual statements.