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

wasm-dbms

license-mit repo-stars downloads latest-version conventional-commits

ci coveralls docs


What is wasm-dbms?

wasm-dbms is an embeddable relational database engine written in Rust, designed to run entirely inside WebAssembly runtimes. Unlike traditional databases that run as external services, wasm-dbms compiles into your WASM module and manages data directly in linear memory — no network calls, no external dependencies.

You define your schema as Rust structs with derive macros, and wasm-dbms provides full CRUD operations, ACID transactions, foreign key integrity, validation, and sanitization — all running within the sandbox of your WASM module.

wasm-dbms supports any WASM runtime (Wasmtime, Wasmer, WasmEdge). For the Internet Computer, see the ic-dbms project.


Why wasm-dbms?

A WASM module is a sandbox with a linear memory and little else. Most embedded databases expect a filesystem and a C toolchain, so using them from WASM means porting a native engine, depending on storage provided by one specific host, or building tables by hand on a key-value store. wasm-dbms is a relational engine designed for the sandbox instead:

  • Runs wherever WASM runs: pure Rust, builds for wasm32-unknown-unknown, no C toolchain, WASI or JavaScript glue required
  • Storage is a trait: the engine works on 64 KiB pages behind MemoryProvider, so the same database runs on the heap, on a file, on Internet Computer stable memory, or on your own storage
  • The schema is Rust code: tables are structs, queries are typed, and mistakes are compile errors
  • Relational, not key-value: foreign keys, joins, transactions, indexes and migrations are built in
  • Ships inside your module: no connection, no network round trip, no service to operate

Read Why wasm-dbms? for the full reasoning and a comparison with SQLite on WASM, Turso, GlueSQL, DuckDB-Wasm, PGlite, key-value stores and host-provided storage.


Documentation

Guides

Step-by-step guides for building databases with wasm-dbms:

  • Getting Started - Set up your first wasm-dbms database
  • CRUD Operations - Insert, select, update, and delete records
  • Querying - Filters, ordering, pagination, and field selection
  • Transactions - ACID transactions with commit/rollback
  • SQL - Query and modify data with SQL statements
  • Relationships - Foreign keys, delete behaviors, and eager loading
  • Custom Data Types - Define your own data types (enums, structs)
  • Schema Migrations - Evolve your schema across releases without losing data
  • Wasmtime Example - Using wasm-dbms with the WIT Component Model and Wasmtime
  • Embedding wasm-dbms - Build an application layer: memory provider, context lifetime, transactions across calls, and ownership

Reference

API and type reference documentation:

  • Data Types - All supported column types
  • Schema Definition - Table attributes and generated types
  • Migrations - Schema migration lifecycle, ops, and policy
  • Validation - Built-in and custom validators
  • Sanitization - Built-in and custom sanitizers
  • JSON - JSON data type and filtering
  • SQL - SQL syntax, statements, type conversion, and reserved words
  • Errors - Error types and handling

Internet Computer

Internet Computer support lives in the dedicated ic-dbms project. Read its documentation at https://ic.wasm-dbms.cc.

WASI Integration

For deploying wasm-dbms on WASI runtimes (Wasmer, Wasmtime, WasmEdge):

Technical Documentation

For advanced users and contributors:


Quick Example

Define your schema:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    #[sanitizer(TrimSanitizer)]
    #[validate(MaxStrlenValidator(100))]
    pub name: Text,
    #[validate(EmailValidator)]
    pub email: Text,
}
}

Use the Database trait for CRUD operations:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

// Insert
let user = UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    email: "alice@example.com".into(),
};
database.insert::<User>(user)?;

// Query
let query = Query::builder()
    .filter(Filter::eq("name", Value::Text("Alice".into())))
    .build();
let users = database.select::<User>(query)?;
}

Features

  • Schema-driven: Define tables as Rust structs with derive macros
  • Runtime-agnostic: Works on any WASM runtime, not tied to a specific platform
  • CRUD operations: Full insert, select, update, delete support
  • ACID transactions: Commit/rollback with isolation
  • Foreign keys: Referential integrity with cascade/restrict behaviors
  • Validation & Sanitization: Built-in validators and sanitizers
  • JSON support: Store and query semi-structured data

Why wasm-dbms?


The Problem

A WebAssembly module is a sandbox. It owns a linear memory and whatever the host decides to import, and nothing else: no filesystem, no sockets, no threads, no libc, unless the runtime grants them. Most embedded databases were designed for an operating system instead. They expect files, file locks and memory mapping, and they often need a C toolchain to build.

That leaves three common ways to keep relational data inside a WASM module:

  1. Port a native database to WASM. SQLite, Postgres and DuckDB all have WASM builds. They bring a C or C++ codebase, a storage shim for the missing filesystem, and usually a JavaScript or WASI host to run on.
  2. Use the storage the host provides. Many runtimes expose a key-value or SQL API to their guests. The data then lives outside the module, and the code is tied to that one host.
  3. Build tables on top of a key-value store. You get persistence, and then write the schema, indexes, foreign keys and transactions yourself.

wasm-dbms is a fourth option: a relational engine designed for the sandbox from the start.


What Makes wasm-dbms Different

  • It runs wherever WASM runs. The engine is pure Rust and builds for wasm32-unknown-unknown with plain cargo. It needs no C toolchain, no WASI, and no JavaScript glue.
  • Storage is a trait, not a filesystem. The engine reads and writes 64 KiB pages, the same size as a WASM memory page, through the MemoryProvider trait. The same engine runs on the heap for tests, on a file through WASI, on Internet Computer stable memory through ic-dbms, or on any storage you implement the trait for. WASI hosts with durable key-value storage can use the draft2 key-value provider without a filesystem preopen.
  • The schema is Rust code. Tables are structs with derive macros. Records, insert requests and update requests are generated types, so a wrong column type is a compile error and no query string is parsed at runtime.
  • It is relational, not key-value. Foreign keys with cascade and restrict behaviors, joins, ACID transactions, B+ tree indexes, aggregates and schema migrations are part of the engine.
  • Data rules live next to the schema. Validators and sanitizers are declared on the columns they protect and run on every write.
  • The database ships inside the module. There is no connection, no network round trip and no service to operate. The host only sees pages.
  • It is not limited to Rust hosts. The WIT interface exposes the database as a WebAssembly Component, so hosts written in Go, Python, JavaScript or any language with Component Model tooling can use it.

Comparison with Alternatives

The table below reflects the state of each project in October 2026. If something is out of date, please open an issue.

OptionEngine languageHow you queryWhere the data livesWhere it runs
wasm-dbmsRustTyped Rust API, WIT interfaceAny MemoryProvider: heap, file, key-value, IC stable memory, your ownAny WASM runtime
SQLite compiled to WASMCSQLMemory, OPFS, or files through WASIBrowsers, WASI runtimes
Turso DatabaseRustSQL (SQLite compatible)SQLite file formatNative, browsers through WASM bindings
GlueSQLRustSQL, query builderSwappable storagesNative, browsers and Node.js
DuckDB-WasmC++SQL (analytics)Browser memory, remote filesBrowsers
PGliteCSQL (Postgres)Memory, IndexedDB, filesystemBrowsers, Node.js, Bun
Embedded key-value storesRustGet, put, rangeFile or custom backendNative targets, custom backends elsewhere
Host-provided storageHost specificHost SQL or key-value APIOutside the module, managed by the hostThat host only

SQLite Compiled to WASM

SQLite is the most tested embedded database there is, and it has official and community WASM builds. In Rust, rusqlite uses sqlite-wasm-rs on wasm32-unknown-unknown, which stores data in memory or in the browser’s OPFS and relies on wasm-bindgen or on host functions you provide. Building for WASI requires a C toolchain such as the WASI SDK.

Choose SQLite when you need SQL compatibility, the SQLite file format, or its long production record. Choose wasm-dbms when you want a pure Rust build with no C toolchain, storage that is not a filesystem, and a schema checked by the Rust compiler.

Turso Database

Turso Database is a rewrite of SQLite in Rust. It is compatible with the SQLite SQL dialect and file format, has WASM bindings for browsers, and has not reached 1.0 yet.

Choose Turso when you want SQLite compatibility from a Rust codebase. Choose wasm-dbms when you want typed tables instead of SQL strings and need to plug in your own page storage.

GlueSQL

GlueSQL is a SQL engine written in Rust with swappable storages and a query builder. It is the closest project to wasm-dbms in spirit. Its WASM support is delivered as a JavaScript package for browsers and Node.js, with in-memory and browser storage backends.

Choose GlueSQL when you want SQL over many storage formats, such as JSON, CSV or Parquet files. Choose wasm-dbms when you want schemas generated from Rust structs, with foreign keys, validators and sanitizers declared on the columns, and a page-based storage layer made for WASM memory.

DuckDB-Wasm

DuckDB-Wasm brings the DuckDB analytics engine to the browser. It is built for analytical queries over large datasets and remote files such as Parquet.

Choose DuckDB-Wasm for analytics in the browser. Choose wasm-dbms for transactional workloads: many small reads and writes on related records, in any WASM runtime.

PGlite

PGlite is Postgres compiled to WASM and packaged as a TypeScript library for browsers, Node.js and Bun.

Choose PGlite when you need real Postgres behavior and extensions in a JavaScript application. Choose wasm-dbms when the database must live inside a Rust module on a runtime with no JavaScript.

Embedded Key-Value Stores

Stores such as redb give you ACID key-value storage in pure Rust, and redb accepts a custom storage backend. They do not give you tables: the schema, secondary indexes, foreign keys, joins and validation are yours to write and keep consistent.

Choose a key-value store when your data really is keys and values. Choose wasm-dbms when records reference each other and you want the engine to keep them consistent.

Host-Provided Storage

Many platforms give guests a database through host functions: Spin offers SQLite and key-value stores, the wasi:keyvalue interface standardizes key-value access, and Cloudflare Workers bind to D1. The data lives outside the module, the host manages it, and it can be shared between modules.

Choose host-provided storage when the data must outlive the module or be shared with other services on that platform. Choose wasm-dbms when the module must stay portable across runtimes, or when the runtime offers nothing more than memory, as on the Internet Computer.


When wasm-dbms Is Not the Right Choice

wasm-dbms is not the best tool for every job. Pick an alternative when:

  • You need compatibility with an existing database. If you must open SQLite files, rely on Postgres features or reuse existing database tooling, use SQLite, Turso or PGlite.
  • Your workload is analytical. Scanning and aggregating very large datasets is what DuckDB is built for.
  • You need decades of production hardening. wasm-dbms is younger than SQLite and has not reached 1.0, so its API can still change between releases.
  • The data must be shared across modules or services. An embedded database belongs to one module. Use host-provided storage or an external database.
  • You only need keys and values. A key-value store is simpler and smaller.

Benchmarks

The repository includes a benchmark suite that compares wasm-dbms with in-memory SQLite and DuckDB across CRUD operations, bulk inserts, queries and transactions. Results depend on the machine, so none are published here. Run them yourself from a checkout:

just bench_compare

Get Started

This guide walks you through setting up a database using wasm-dbms. By the end, you’ll have a working database with CRUD operations and transactions.


Prerequisites

Before starting, ensure you have:

  • Rust 1.91.1 or later
  • wasm32-unknown-unknown target: rustup target add wasm32-unknown-unknown

Project Setup

Workspace Structure

We recommend organizing your project as a Cargo workspace with a schema crate:

my-dbms-project/
├── Cargo.toml          # Workspace manifest
├── schema/             # Schema definitions (reusable types)
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs
└── app/                # Your application using the database
    ├── Cargo.toml
    └── src/
        └── lib.rs

Workspace Cargo.toml:

[workspace]
members = ["schema", "app"]
resolver = "2"

Cargo Configuration

Create .cargo/config.toml to configure the getrandom crate for WebAssembly:

[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="custom"']

This is required because the uuid crate depends on getrandom.


Define Your Schema

Create the Schema Crate

Create schema/Cargo.toml:

[package]
name = "my-schema"
version = "0.1.0"
edition = "2024"

[dependencies]
wasm-dbms-api = "0.6"

Define Tables

In schema/src/lib.rs, define your database tables using the Table derive macro:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    #[sanitizer(TrimSanitizer)]
    #[validate(MaxStrlenValidator(100))]
    pub name: Text,
    #[validate(EmailValidator)]
    pub email: Text,
    pub created_at: DateTime,
}

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
    #[primary_key]
    pub id: Uint32,
    #[validate(MaxStrlenValidator(200))]
    pub title: Text,
    pub content: Text,
    pub published: Boolean,
    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,
}
}

Required derives: Table, Clone

The Table macro generates additional types for each table:

Generated TypePurpose
UserRecordFull record returned from queries
UserInsertRequestRequest type for inserting records
UserUpdateRequestRequest type for updating records
UserForeignFetcherInternal type for relationship loading

Define a Database Schema

Once you’ve defined your tables, create a schema struct with #[derive(DatabaseSchema)] to wire them together:

#![allow(unused)]
fn main() {
use wasm_dbms::prelude::DatabaseSchema;

#[derive(DatabaseSchema)]
#[tables(User = "users", Post = "posts")]
pub struct MySchema;
}

The DatabaseSchema derive macro auto-generates the DatabaseSchema<M> trait implementation and a register_tables method. This replaces what would otherwise be ~130+ lines of manual dispatch code.


Using the Database

Create a DbmsContext

The DbmsContext holds all database state. Create one using a MemoryProvider:

#![allow(unused)]
fn main() {
use wasm_dbms::prelude::*;
use wasm_dbms_api::prelude::*;

// For testing, use HeapMemoryProvider
let ctx = DbmsContext::new(HeapMemoryProvider::default());

// Register tables from the schema
MySchema::register_tables(&ctx).expect("failed to register tables");
}

Perform CRUD Operations

Create a WasmDbmsDatabase from the context to perform operations:

#![allow(unused)]
fn main() {
use wasm_dbms::prelude::*;
use my_schema::{User, UserInsertRequest};

// Create a one-shot (non-transactional) database
let database = WasmDbmsDatabase::oneshot(&ctx, MySchema);

// Insert a record
let user = UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    email: "alice@example.com".into(),
    created_at: DateTime::now(),
};
database.insert::<User>(user)?;

// Query records
let query = Query::builder().all().build();
let users = database.select::<User>(query)?;
}

Quick Example: Complete Workflow

Here’s a complete example showing insert, query, update, and delete operations:

#![allow(unused)]
fn main() {
use my_schema::{User, UserInsertRequest, UserUpdateRequest};
use wasm_dbms_api::prelude::*;

fn example(database: &impl Database) -> Result<(), DbmsError> {
    // 1. INSERT a new user
    let insert_req = UserInsertRequest {
        id: 1.into(),
        name: "Alice".into(),
        email: "alice@example.com".into(),
        created_at: DateTime::now(),
    };
    database.insert::<User>(insert_req)?;

    // 2. SELECT users
    let query = Query::builder()
        .filter(Filter::eq("name", Value::Text("Alice".into())))
        .build();
    let users = database.select::<User>(query)?;
    println!("Found {} user(s)", users.len());

    // 3. UPDATE the user
    let update_req = UserUpdateRequest::builder()
        .set_email("alice.new@example.com".into())
        .filter(Filter::eq("id", Value::Uint32(1.into())))
        .build();
    let updated = database.update::<User>(update_req)?;
    println!("Updated {} record(s)", updated);

    // 4. DELETE the user
    let deleted = database.delete::<User>(
        DeleteBehavior::Restrict,
        Some(Filter::eq("id", Value::Uint32(1.into()))),
    )?;
    println!("Deleted {} record(s)", deleted);

    Ok(())
}
}

Testing with HeapMemoryProvider

For unit tests, use HeapMemoryProvider which stores data in heap memory:

#![allow(unused)]
fn main() {
use my_schema::{MySchema, User, UserInsertRequest};
use wasm_dbms::prelude::*;
use wasm_dbms_api::prelude::*;

#[test]
fn test_insert_and_select() {
    let ctx = DbmsContext::new(HeapMemoryProvider::default());
    MySchema::register_tables(&ctx).expect("register failed");
    let database = WasmDbmsDatabase::oneshot(&ctx, MySchema);

    let insert_req = UserInsertRequest {
        id: 1.into(),
        name: "Test User".into(),
        email: "test@example.com".into(),
        created_at: DateTime::now(),
    };

    database.insert::<User>(insert_req).expect("insert failed");

    let query = Query::builder().all().build();
    let users = database.select::<User>(query).expect("select failed");

    assert_eq!(users.len(), 1);
    assert_eq!(users[0].name.as_str(), "Test User");
}
}

For the Internet Computer, see the ic-dbms Getting Started Guide.


Next Steps

Now that you have a working database, explore these topics:

CRUD Operations


Overview

wasm-dbms provides four fundamental database operations through the Database trait:

OperationDescriptionReturns
InsertAdd a new record to a tableResult<()>
SelectQuery records from a tableResult<Vec<Record>>
UpdateModify existing recordsResult<u64> (affected rows)
DeleteRemove records from a tableResult<u64> (affected rows)

All operations:

  • Support optional transaction IDs
  • Validate and sanitize data according to schema rules
  • Enforce foreign key constraints

Insert

Basic Insert

To insert a record, create an InsertRequest and call the insert method:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;
use my_schema::{User, UserInsertRequest};

let user = UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    email: "alice@example.com".into(),
    created_at: DateTime::now(),
};

// Insert without transaction
database.insert::<User>(user)?;
}

Handling Primary Keys

Every table must have a primary key. Insert will fail if a record with the same primary key already exists:

#![allow(unused)]
fn main() {
// First insert succeeds
database.insert::<User>(user1)?;

// Second insert with same ID fails with PrimaryKeyConflict
let result = database.insert::<User>(user2_same_id);
assert!(matches!(result, Err(DbmsError::Query(QueryError::PrimaryKeyConflict))));
}

Nullable Fields

For fields wrapped in Nullable<T>, you can insert either a value or null:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "profiles"]
pub struct Profile {
    #[primary_key]
    pub id: Uint32,
    pub bio: Nullable<Text>,      // Optional field
    pub website: Nullable<Text>,  // Optional field
}

// Insert with value
let profile = ProfileInsertRequest {
    id: 1.into(),
    bio: Nullable::Value("Hello world".into()),
    website: Nullable::Null,  // No website
};

database.insert::<Profile>(profile)?;
}

Insert with Transaction

To insert within a transaction, use a transactional database instance:

#![allow(unused)]
fn main() {
// Begin transaction
let tx_id = ctx.begin_transaction();

// Create a transactional database
let database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

// Insert within transaction
database.insert::<User>(user)?;

// Commit or rollback
database.commit()?;
}

Select

Select All Records

Use Query::builder().all() to select all records:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

let query = Query::builder().all().build();
let users: Vec<UserRecord> = database.select::<User>(query)?;

for user in users {
    println!("User: {} ({})", user.name, user.email);
}
}

Select with Filter

Add filters to narrow down results:

#![allow(unused)]
fn main() {
// Select users with specific name
let query = Query::builder()
    .filter(Filter::eq("name", Value::Text("Alice".into())))
    .build();

let users = database.select::<User>(query)?;
}

See the Querying Guide for comprehensive filter documentation.

Select Specific Columns

Select only the columns you need:

#![allow(unused)]
fn main() {
let query = Query::builder()
    .columns(vec!["id".to_string(), "name".to_string()])
    .build();

let users = database.select::<User>(query)?;
// Only id and name are populated; other fields have default values
}

Select with Eager Loading

Load related records in a single query using with():

#![allow(unused)]
fn main() {
// Load posts with their authors
let query = Query::builder()
    .all()
    .with("users")  // Eager load the related users table
    .build();

let posts = database.select::<Post>(query)?;
}

See the Relationships Guide for more on eager loading.


Update

Basic Update

Create an UpdateRequest to modify records:

#![allow(unused)]
fn main() {
use my_schema::UserUpdateRequest;

let update = UserUpdateRequest::builder()
    .set_name("Alice Smith".into())
    .filter(Filter::eq("id", Value::Uint32(1.into())))
    .build();

let affected_rows = database.update::<User>(update)?;

println!("Updated {} row(s)", affected_rows);
}

Partial Updates

Only specify the fields you want to change. Unspecified fields remain unchanged:

#![allow(unused)]
fn main() {
// Only update the email, keep everything else
let update = UserUpdateRequest::builder()
    .set_email("new.email@example.com".into())
    .filter(Filter::eq("id", Value::Uint32(1.into())))
    .build();

database.update::<User>(update)?;
}

Type Checking

Every value of an update patch must match the type of its column; Null is accepted only by nullable columns. Building a patch with UpdateRecord::from_values, or sending one through the dynamic schema update, returns a QueryError::InvalidQuery for a mismatched value instead of ignoring it. See Errors.

#![allow(unused)]
fn main() {
let update = UserUpdateRequest::from_values(
    &[(email_col, Value::Text("alice@example.com".into()))],
    Some(Filter::eq("id", Value::Uint32(1.into()))),
)?;
database.update::<User>(update)?;
}

Update with Filter

The filter determines which records are updated:

Updates find their target rows with the same index planning as queries, including composite indexes, OR, AND, and NULL checks; see Index-Accelerated Queries.

#![allow(unused)]
fn main() {
// Update all users with a specific domain
let update = UserUpdateRequest::builder()
    .set_verified(true.into())
    .filter(Filter::like("email", "%@company.com"))
    .build();

let affected = database.update::<User>(update)?;
println!("Verified {} company users", affected);
}

Update Return Value

Update returns the number of affected rows:

#![allow(unused)]
fn main() {
let affected = database.update::<User>(update)?;

if affected == 0 {
    println!("No records matched the filter");
} else {
    println!("Updated {} record(s)", affected);
}
}

Delete

Delete with Filter

Delete records matching a filter:

Deletes use the same index planning as queries, including composite indexes, OR, AND, and NULL checks; see Index-Accelerated Queries.

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::DeleteBehavior;

let filter = Filter::eq("id", Value::Uint32(1.into()));

let deleted = database.delete::<User>(
    DeleteBehavior::Restrict,
    Some(filter),
)?;

println!("Deleted {} record(s)", deleted);
}

Delete Behaviors

When deleting records that are referenced by foreign keys, you must specify a behavior:

BehaviorDescription
RestrictFail if any foreign keys reference this record
CascadeDelete all records that reference this record

Restrict Example:

#![allow(unused)]
fn main() {
// Will fail if any posts reference this user
let result = database.delete::<User>(
    DeleteBehavior::Restrict,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
);

match result {
    Ok(count) => println!("Deleted {} user(s)", count),
    Err(DbmsError::Query(QueryError::ForeignKeyConstraintViolation)) => {
        println!("Cannot delete: user has posts");
    }
    Err(e) => return Err(e),
}
}

Cascade Example:

#![allow(unused)]
fn main() {
// Deletes the user AND all their posts
database.delete::<User>(
    DeleteBehavior::Cascade,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
)?;
}

Delete All Records

Pass None as the filter to delete all records (use with caution):

#![allow(unused)]
fn main() {
// Delete ALL users (respecting foreign key behavior)
let deleted = database.delete::<User>(
    DeleteBehavior::Cascade,
    None,  // No filter = all records
)?;

println!("Deleted all {} users and their related records", deleted);
}

Operations with Transactions

All CRUD operations can be performed within a transaction. When using a transactional database instance, operations won’t be visible to other callers until committed:

#![allow(unused)]
fn main() {
// Begin transaction
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

// Perform operations within transaction
database.insert::<User>(user1)?;
database.insert::<User>(user2)?;

// Update within same transaction
let update = UserUpdateRequest::builder()
    .set_verified(true.into())
    .filter(Filter::all())
    .build();
database.update::<User>(update)?;

// Commit all changes atomically
database.commit()?;
}

See the Transactions Guide for comprehensive transaction documentation.


Error Handling

CRUD operations can fail for various reasons. Here are common errors:

ErrorCauseOperation
PrimaryKeyConflictRecord with same primary key existsInsert
ForeignKeyConstraintViolationReferenced record doesn’t exist, or delete restrictedInsert, Update, Delete
BrokenForeignKeyReferenceForeign key points to non-existent recordInsert, Update
UnknownColumnInvalid column name in filter or selectSelect, Update, Delete
MissingNonNullableFieldRequired field not providedInsert, Update
RecordNotFoundNo record matches the criteriaUpdate, Delete
TransactionNotFoundInvalid transaction IDAll
InvalidQueryMalformed query (e.g., invalid JSON path)Select

Example error handling:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{DbmsError, QueryError};

let result = database.insert::<User>(user);

match result {
    Ok(()) => println!("Insert successful"),
    Err(DbmsError::Query(QueryError::PrimaryKeyConflict)) => {
        println!("User with this ID already exists");
    }
    Err(DbmsError::Query(QueryError::BrokenForeignKeyReference)) => {
        println!("Referenced record does not exist");
    }
    Err(DbmsError::Validation(msg)) => {
        println!("Validation failed: {}", msg);
    }
    Err(e) => {
        println!("Unexpected error: {:?}", e);
    }
}
}

See the Errors Reference for complete error documentation.

For the Internet Computer, see the ic-dbms CRUD Guide.

Querying


Overview

wasm-dbms provides a powerful query API for retrieving data from your tables. Queries are built using the QueryBuilder and can include:

  • Filters - Narrow down which records to return
  • Ordering - Sort results by one or more columns
  • Pagination - Limit results and implement pagination
  • Field Selection - Choose which columns to return
  • Eager Loading - Load related records in a single query
  • Joins - Combine rows from multiple tables

Query Builder

Basic Queries

Use Query::builder() to construct queries:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

// Select all records
let query = Query::builder().all().build();

// Select with filter
let query = Query::builder()
.filter(Filter::eq("status", Value::Text("active".into())))
.build();

// Complex query with multiple options
let query = Query::builder()
.filter(Filter::gt("age", Value::Int32(18.into())))
.order_by("created_at", OrderDirection::Descending)
.limit(10)
.offset(20)
.build();
}

Query Structure

A query consists of these optional components:

ComponentMethodDescription
Filter.filter()Which records to return
Select.all() or .columns()Which columns to return
Order.order_by()Sort order
Limit.limit()Maximum records to return
Offset.offset()Records to skip
Eager Loading.with()Related tables to load
Join.inner_join(), etc.Cross-table join

Filters

Filters determine which records match your query. All filters are created using the Filter struct.

Comparison Filters

FilterDescriptionExample
Filter::eq()Equal toFilter::eq("status", Value::Text("active".into()))
Filter::ne()Not equal toFilter::ne("status", Value::Text("deleted".into()))
Filter::gt()Greater thanFilter::gt("age", Value::Int32(18.into()))
Filter::ge()Greater than or equalFilter::ge("score", Value::Decimal(90.0.into()))
Filter::lt()Less thanFilter::lt("price", Value::Decimal(100.0.into()))
Filter::le()Less than or equalFilter::le("quantity", Value::Int32(10.into()))

Examples:

#![allow(unused)]
fn main() {
// Find users older than 21
let filter = Filter::gt("age", Value::Int32(21.into()));

// Find products under $50
let filter = Filter::lt("price", Value::Decimal(50.0.into()));

// Find orders from a specific date
let filter = Filter::ge("created_at", Value::DateTime(some_datetime));
}

List Membership

Check if a value is in a list of values:

#![allow(unused)]
fn main() {
// Find users with specific roles
let filter = Filter::in_list("role", vec![
    Value::Text("admin".into()),
    Value::Text("moderator".into()),
    Value::Text("editor".into()),
]);

// Find products in certain categories
let filter = Filter::in_list("category_id", vec![
    Value::Uint32(1.into()),
    Value::Uint32(2.into()),
    Value::Uint32(5.into()),
]);
}

Pattern Matching

Use like for pattern matching with wildcards:

PatternMatches
%Any sequence of characters
_Any single character
\%Literal % character
\_Literal _ character
\\Literal \ character

A backslash escapes the character that follows it. A pattern that ends with an unescaped backslash, such as abc\, is rejected with QueryError::InvalidQuery. Write abc\\ to match text ending with a literal backslash.

#![allow(unused)]
fn main() {
// Find users whose email ends with @company.com
let filter = Filter::like("email", "%@company.com");

// Find products starting with "Pro"
let filter = Filter::like("name", "Pro%");

// Find codes with pattern XX-###
let filter = Filter::like("code", "__-___");

// Find text containing literal %
let filter = Filter::like("description", r"%25\% off%");
}

Null Checks

Check for null or non-null values:

#![allow(unused)]
fn main() {
// Find users without a phone number
let filter = Filter::is_null("phone");

// Find users with a profile picture
let filter = Filter::not_null("avatar_url");
}

A NULL value in a nullable Text or Json column never matches a like filter or a JSON filter, so the row is left out of the result instead of failing the query. The pattern or JSON path is still checked, and an invalid one returns an error. not() inverts the result, so a negated like or JSON filter matches NULL values, in the same way ne matches them. Combine the filter with not_null to leave NULL values out:

#![allow(unused)]
fn main() {
// Users whose nickname does not start with "x", without users lacking a nickname
let filter = Filter::like("nickname", "x%")
.not()
.and(Filter::not_null("nickname"));
}

The same rules apply to plain queries and to join filters, where the columns of an unmatched side of a LEFT, RIGHT, or FULL join are NULL.

Combining Filters

Filters can be combined using logical operators:

AND - Both conditions must match:

#![allow(unused)]
fn main() {
// Active users over 18
let filter = Filter::eq("status", Value::Text("active".into()))
.and(Filter::gt("age", Value::Int32(18.into())));
}

OR - Either condition matches:

#![allow(unused)]
fn main() {
// Admins or moderators
let filter = Filter::eq("role", Value::Text("admin".into()))
.or(Filter::eq("role", Value::Text("moderator".into())));
}

NOT - Negate a condition:

#![allow(unused)]
fn main() {
// Users who are not banned
let filter = Filter::eq("status", Value::Text("banned".into())).not();
}

Complex combinations:

#![allow(unused)]
fn main() {
// (active AND age > 18) OR role = "admin"
let filter = Filter::eq("status", Value::Text("active".into()))
.and(Filter::gt("age", Value::Int32(18.into())))
.or(Filter::eq("role", Value::Text("admin".into())));

// NOT (deleted OR archived)
let filter = Filter::eq("status", Value::Text("deleted".into()))
.or(Filter::eq("status", Value::Text("archived".into())))
.not();
}

JSON Filters

For columns with Json type, use specialized JSON filters. See the JSON Reference for comprehensive documentation.

Quick examples:

#![allow(unused)]
fn main() {
// Check if JSON contains a pattern
let pattern = Json::from_str(r#"{"active": true}"#).unwrap();
let filter = Filter::json("metadata", JsonFilter::contains(pattern));

// Extract and compare a value
let filter = Filter::json(
"settings",
JsonFilter::extract_eq("theme", Value::Text("dark".into()))
);

// Check if a path exists
let filter = Filter::json("data", JsonFilter::has_key("user.email"));
}

Ordering

Single Column Ordering

Sort results by a single column:

#![allow(unused)]
fn main() {
// Sort by name ascending (A-Z)
let query = Query::builder()
.all()
.order_by("name", OrderDirection::Ascending)
.build();

// Sort by created_at descending (newest first)
let query = Query::builder()
.all()
.order_by("created_at", OrderDirection::Descending)
.build();
}

Multiple Column Ordering

Chain multiple order_by calls for secondary sorting:

#![allow(unused)]
fn main() {
// Sort by category, then by price within each category
let query = Query::builder()
.all()
.order_by("category", OrderDirection::Ascending)
.order_by("price", OrderDirection::Descending)
.build();

// Sort by status, then by priority, then by created_at
let query = Query::builder()
.all()
.order_by("status", OrderDirection::Ascending)
.order_by("priority", OrderDirection::Descending)
.order_by("created_at", OrderDirection::Ascending)
.build();
}

Pagination

Limit

Restrict the number of records returned:

#![allow(unused)]
fn main() {
// Get only the first 10 records
let query = Query::builder()
.all()
.limit(10)
.build();
}

Offset

Skip a number of records before returning results:

#![allow(unused)]
fn main() {
// Skip the first 20 records
let query = Query::builder()
.all()
.offset(20)
.build();
}

Pagination Pattern

Combine limit and offset for pagination:

#![allow(unused)]
fn main() {
const PAGE_SIZE: u64 = 20;

fn get_page_query(page: u64) -> Query {
    Query::builder()
        .all()
        .order_by("id", OrderDirection::Ascending)  // Consistent ordering is important
        .limit(PAGE_SIZE)
        .offset(page * PAGE_SIZE)
        .build()
}

// Page 0: records 0-19
let page_0 = get_page_query(0);

// Page 1: records 20-39
let page_1 = get_page_query(1);

// Page 2: records 40-59
let page_2 = get_page_query(2);
}

Tip: Always use order_by with pagination to ensure consistent ordering across pages.


Field Selection

Select All Fields

Use .all() to select all columns:

#![allow(unused)]
fn main() {
let query = Query::builder()
.all()
.build();

let users = database.select::<User>(query)?;
// All fields are populated
}

Select Specific Fields

Use .columns() to select only specific columns:

#![allow(unused)]
fn main() {
let query = Query::builder()
.columns(vec!["id".to_string(), "name".to_string(), "email".to_string()])
.build();

let users = database.select::<User>(query)?;
// Only id, name, and email are populated
// Other fields will have default values
}

Note: The primary key is always included, even if not specified.


Eager Loading

Load related records in a single query using .with():

#![allow(unused)]
fn main() {
// Define tables with foreign key
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,
}

// Query posts with authors eagerly loaded
let query = Query::builder()
.all()
.with("users")
.build();

let posts = database.select::<Post>(query)?;
}

See the Relationships Guide for more on eager loading.


Distinct

Use .distinct(&[...]) to remove duplicate rows from the result set based on one or more columns. Rows are deduplicated by the tuple of values across the listed columns; the first row encountered for each distinct tuple is kept.

Basic Distinct

#![allow(unused)]
fn main() {
// Get the unique set of names from the users table
let query = Query::builder()
    .all()
    .distinct(&["name"])
    .build();

let users = database.select::<User>(query)?;
}

Distinct by Multiple Columns

#![allow(unused)]
fn main() {
// Unique (category, vendor) pairs from products
let query = Query::builder()
    .all()
    .distinct(&["category", "vendor"])
    .build();

let products = database.select::<Product>(query)?;
}

Distinct with Ordering and Pagination

DISTINCT runs before ORDER BY, OFFSET, and LIMIT, so paging through distinct values works as expected:

#![allow(unused)]
fn main() {
// Page 2 (size 10) of unique names, alphabetical
let query = Query::builder()
    .all()
    .distinct(&["name"])
    .order_by_asc("name")
    .offset(10)
    .limit(10)
    .build();
}

Without DISTINCT, LIMIT 10 could yield ten copies of the same name. With DISTINCT, the limit applies to the deduplicated stream.

Distinct Semantics

  • Lookup is performed against the source row’s columns. The columns named in .distinct(...) do not need to be in the field selection.
  • A column not present on the row is treated as Value::Null. Listing an unknown column collapses every row into a single result.
  • Calling .distinct(&[]) (or omitting it) is a no-op.
  • Pipeline order: WHERE -> DISTINCT -> eager loading -> column selection -> ORDER BY -> OFFSET / LIMIT. See the Query API Reference for the full pipeline.

Tip: distinct(&[pk_column]) returns at most one row per primary key, which can be useful when joining sources that fan out the parent rows.


Aggregations

Aggregations summarise groups of rows using COUNT, SUM, AVG, MIN, and MAX. Group rows with .group_by(...), filter the resulting groups with .having(...), and describe the aggregates to compute via the AggregateFunction enum.

Defining Aggregates

Each aggregate is one variant of AggregateFunction:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::AggregateFunction;

let aggregates = vec![
    AggregateFunction::Count(None),               // COUNT(*)
    AggregateFunction::Count(Some("email".into())), // COUNT(email)
    AggregateFunction::Sum("amount".into()),
    AggregateFunction::Avg("amount".into()),
    AggregateFunction::Min("created_at".into()),
    AggregateFunction::Max("created_at".into()),
];
}

Count(None) counts every row in the group; Count(Some(col)) counts only rows where col is non-null. The other variants take a column name and operate over its values.

Group By and Having

Use .group_by(&[...]) to define grouping keys and .having(filter) to filter the aggregated groups:

#![allow(unused)]
fn main() {
let query = Query::builder()
    .all()
    .group_by(&["category"])
    .having(Filter::gt("count", Value::Uint64(10u64.into())))
    .order_by_desc("category")
    .build();
}

HAVING is evaluated after aggregation, against grouping keys and aggregate results. WHERE (set with .and_where() / .or_where()) still applies first to the raw rows.

Aggregate Result Types

Aggregated queries return AggregatedRow values:

#![allow(unused)]
fn main() {
pub struct AggregatedRow {
    pub group_keys: Vec<Value>,
    pub values: Vec<AggregatedValue>,
}
}

group_keys carries the grouping tuple (one Value per group_by column). values holds one AggregatedValue per requested aggregate, in the same order as the AggregateFunction list.

#![allow(unused)]
fn main() {
pub enum AggregatedValue {
    Count(u64),
    Sum(Value),
    Avg(Value),
    Min(Value),
    Max(Value),
}
}

Count is always u64; the other variants wrap a Value whose concrete variant matches the source column’s data type.

See the Query API Reference for the full type definitions and pipeline ordering.


Joins

Joins combine rows from two or more tables based on a related column, producing a single result set with columns from all joined tables. Use joins when you need to correlate data across tables in a single flat result – for example, listing posts alongside their author names.

Note: Joins require the select_join method, which returns a JoinResultSet: the JoinColumnDef of every selected column once, each with its source table name, followed by the rows as plain Vec<Value>. Typed select::<T> rejects queries that contain joins with a JoinInsideTypedSelect error.

Join Types

TypeBuilder MethodDescription
INNER.inner_join()Returns only rows where both sides match
LEFT.left_join()Returns all left rows; unmatched right columns are NULL
RIGHT.right_join()Returns all right rows; unmatched left columns are NULL
FULL.full_join()Returns all rows from both sides; unmatched columns are NULL

Basic Join

Use .inner_join(table, left_column, right_column) to join two tables:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

// Join users with their posts (INNER JOIN)
let query = Query::builder()
    .all()
    .inner_join("posts", "id", "user_id")
    .build();

// select_join returns the column descriptions once, then the rows
let result = database.select_join("users", query)?;

// Each JoinRow pairs every value with its column description
for row in &result {
    for (col_def, value) in row.iter() {
        // col_def.table tells you which table the column came from
        let table = col_def.table.as_deref().unwrap_or("?");
        println!("{table}.{column} = {value:?}", column = col_def.name.as_str());
    }
}

// Resolve column positions once when reading many rows
let name_index = result
    .column_index("users.name")
    .expect("users.name is selected");
let title_index = result
    .column_index("posts.title")
    .expect("posts.title is selected");
for row in &result.rows {
    println!(
        "{user_name:?} wrote {post_title:?}",
        user_name = &row[name_index],
        post_title = &row[title_index]
    );
}

// Or look up a value directly from a borrowed row
if let Some(row) = result.row(0) {
    let first_title = row.get("posts.title");
    let any_id = row.get("id"); // unqualified: first column named "id"
}
}

A bare column name resolves to the first column with that name in row order, so qualify names that exist on both sides (users.id and posts.id). result.columns is the complete header even when result.rows is empty.

Left, Right, and Full Joins

#![allow(unused)]
fn main() {
// LEFT JOIN: all users, even those without posts
let query = Query::builder()
    .all()
    .left_join("posts", "id", "user_id")
    .build();

// RIGHT JOIN: all posts, even those with missing/deleted authors
let query = Query::builder()
    .all()
    .right_join("posts", "id", "user_id")
    .build();

// FULL JOIN: all users and all posts, matched where possible
let query = Query::builder()
    .all()
    .full_join("posts", "id", "user_id")
    .build();
}

Join keys match only when both values are non-null and equal. A NULL join key does not match another NULL. For LEFT, RIGHT, and FULL joins, the row remains unmatched and columns from the missing side are filled with Value::Null.

Chaining Multiple Joins

Chain multiple joins to combine more than two tables:

#![allow(unused)]
fn main() {
// Users -> Posts -> Comments
let query = Query::builder()
    .all()
    .inner_join("posts", "id", "user_id")
    .left_join("comments", "posts.id", "post_id")
    .build();

let result = database.select_join("users", query)?;
}

Joins are processed left-to-right. The second join operates on the result of the first.

Qualified Column Names

When joining tables that share column names, use table.column syntax to disambiguate:

#![allow(unused)]
fn main() {
// Both "users" and "posts" have an "id" column
let query = Query::builder()
    .field("users.id")
    .field("users.name")
    .field("posts.title")
    .inner_join("posts", "users.id", "user_id")
    .and_where(Filter::eq("users.name", Value::Text("Alice".into())))
    .order_by_asc("posts.title")
    .build();
}

Qualified names (table.column) work in:

  • Field selection (.field())
  • Filters (.and_where(), .or_where())
  • Ordering (.order_by_asc(), .order_by_desc())
  • Join ON conditions

Unqualified names default to the FROM table (the table passed to select_join).

Joins vs Eager Loading

Eager LoadingJoins
Result typeTyped (Vec<T>)JoinResultSet (columns once, rows of Value)
Result formatSeparate related recordsFlat combined rows
API methodselect::<T>select_join
Column disambiguationNot neededUse table.column syntax
Use caseLoad parent with childrenCorrelate columns across tables

Use eager loading when you want typed results with related records attached. Use joins when you need a flat, cross-table result set – for example, for reporting, search, or when you need columns from multiple tables in a single row.


Index-Accelerated Queries

When a table has indexes defined (via #[index] or the automatic primary key index), the query engine can use them to avoid full table scans. This happens transparently — you write the same filters as before, and the engine picks the best available index.

How Indexes Improve Queries

Without indexes, every SELECT, UPDATE, and DELETE scans all records in the table. With an index on the filtered column, the engine navigates the B-tree to locate matching records directly, then loads only those records from memory. Covered projections can return values from index keys without loading record pages.

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    #[index]
    pub email: Text,
    pub name: Text,
}

// This uses the index on `email` — no full table scan
let query = Query::builder()
    .filter(Filter::eq("email", Value::Text("alice@example.com".into())))
    .build();

let users = database.select::<User>(query)?;
}

Which Filters Use Indexes

The planner reads the conditions combined with AND, in any order and nesting, and matches them against every index of the table:

FilterIndex access
Filter::eq("col", val)Exact key lookup
Filter::in_list("col", vals)One exact lookup per distinct value
ge, gt, le, lt on one columnRange scan with inclusive/exclusive bounds
Several bounds on the same columnThe strictest bounds
Filter::is_null("col")Exact lookup of the NULL key
Filter::not_null("col")Range of the non-NULL keys
a OR b where every branch is indexableUnion of the branch lookups

Each result is checked against the query’s filter, so an index narrows the rows to consider without changing which rows qualify. Contradictory conditions such as price >= 5 AND price < 5 or an empty in_list return no rows without reading the table.

Composite Indexes

A composite index is used from its first column onward, in declaration order. For an index on (category, brand, price):

  • Equality on all three columns is one exact lookup.
  • Equality on category reads only that category.
  • Equality on category and a range on brand reads only that range.
  • A condition on brand or price alone cannot use the index.
  • If an index column has no condition, conditions on later columns are checked on the candidate rows instead.

A leading in_list expands into at most 64 index ranges. Above that limit, the engine falls back or uses a narrower index path available from other conditions.

OR and AND Across Indexes

a OR b uses an index when every branch can; the results are merged and each row is returned once. If one branch has no usable index, the whole OR falls back, although another AND condition outside the OR may still use an index. At most 64 index ranges are read for one OR.

For a AND b on separately indexed columns, the engine prefers a primary-key, unique, or complete composite equality. Otherwise it intersects useful index paths and keeps only rows present in each chosen path before loading records.

Null Checks on Indexed Columns

is_null is an exact lookup. not_null reads the non-NULL part of the index: signed integers, dates, date-times, decimals, booleans, blobs, and JSON sort below NULL; text and unsigned integers sort above it. Nullable Uuid and custom columns have no proven range and use a table scan.

Covering Reads

Outside a transaction, one index can build rows from keys alone when it contains every selected, filtered, ordered, and distinct column, the query loads no relations, and either the filter plans to one range or no filter is given. Duplicate keys still produce one result per record. A secondary index contains the primary key only when the index definition lists it.

Without ordering or distinct processing, covering reads stop after enough matching rows have been read to satisfy the offset and limit. A zero limit does not read index entries.

When the Engine Scans Instead

The engine uses a full table scan with the same results when no index path can provide a complete candidate set. This includes queries where no condition matches an index and the query is not eligible for an unfiltered covering read. For record-fetching plans, a range that would materialize more than 4,096 candidates or a plan that would materialize more than 16,384 also triggers a scan unless another complete index path narrows the candidates within the limit. Covering reads stream index keys instead of materializing addresses. Results are never truncated.

Filters containing like or JSON keep the earlier single-column index plan and residual evaluation so their error and short-circuit behavior stays unchanged.

Transaction-Aware Lookups

Inside a transaction, the engine reads committed index entries, applies the transaction’s inserts, updates, and deletes to the candidate rows, adds rows the transaction moved into the filter, and checks every row against the whole filter. Updates to columns outside the chosen index conditions do not add extra record fetches. Filters containing like or JSON first check the visible index key and then evaluate the earlier residual filter, preserving their error behavior. A row is returned once even when it moved between OR branches or changed its primary key. On commit the indexes are updated; on rollback they are unchanged.

Relationships


Overview

wasm-dbms supports foreign key relationships between tables, providing:

  • Referential integrity: Ensures foreign keys point to valid records
  • Delete behaviors: Control what happens when referenced records are deleted
  • Eager loading: Load related records in a single query

Defining Foreign Keys

Foreign Key Syntax

Use the #[foreign_key] attribute to define relationships:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    pub content: Text,

    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,
}
}

Attribute parameters:

ParameterDescription
entityThe Rust struct name of the referenced table
tableThe table name (as specified in #[table = "..."])
columnThe column in the referenced table (usually the primary key)

Foreign Key Constraints

When you define a foreign key:

  1. The field type must match the referenced column type
  2. The referenced table must be registered in your database schema
  3. Foreign key values must reference existing records (enforced on insert/update)

Existence checks, eager loading and delete behaviors all match foreign key values against the declared column. A referenced column other than the primary key should be #[unique], so that each value identifies a single record.

When an update changes the referenced column of a record, whether it is the primary key or another column, the new value is written to every referencing row, so references stay valid. An update that leaves the referenced column unchanged does not touch the referencing rows.

Nullable Foreign Keys

Declare an optional relation by wrapping the foreign key type in Nullable:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "employees"]
pub struct Employee {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    #[foreign_key(entity = "Employee", table = "employees", column = "id")]
    pub manager_id: Nullable<Uint32>,
}
}

A null foreign key references no record, so inserts and updates skip the existence check for it. In the generated record, the relation field has the same type as for a required foreign key, Option<Box<EmployeeRecord>>. It is Some when the relation is eager loaded and the foreign key is set, and None when the foreign key is null or the relation is not eager loaded.


Referential Integrity

wasm-dbms enforces referential integrity automatically.

Insert Validation

When inserting a record with a foreign key, the referenced record must exist:

#![allow(unused)]
fn main() {
// This user exists
database.insert::<User>(UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    ..
})?;

// Insert post referencing existing user - OK
database.insert::<Post>(PostInsertRequest {
    id: 1.into(),
    title: "My Post".into(),
    author_id: 1.into(),  // User 1 exists
    ..
})?;

// Insert post referencing non-existent user - FAILS
let result = database.insert::<Post>(PostInsertRequest {
    id: 2.into(),
    title: "Another Post".into(),
    author_id: 999.into(),  // User 999 doesn't exist
    ..
});

assert!(matches!(
    result,
    Err(DbmsError::Query(QueryError::BrokenForeignKeyReference))
));
}

Update Validation

Updates are also validated:

#![allow(unused)]
fn main() {
// Changing author_id to non-existent user fails
let update = PostUpdateRequest::builder()
    .set_author_id(999.into())  // User 999 doesn't exist
    .filter(Filter::eq("id", Value::Uint32(1.into())))
    .build();

let result = database.update::<Post>(update);
assert!(matches!(
    result,
    Err(DbmsError::Query(QueryError::BrokenForeignKeyReference))
));
}

Delete Behaviors

When deleting a record that is referenced by other records, you must specify how to handle the references.

Restrict

Behavior: Fail if any records reference this one.

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::DeleteBehavior;

// User has posts - delete fails
let result = database.delete::<User>(
    DeleteBehavior::Restrict,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
);

match result {
    Err(DbmsError::Query(QueryError::ForeignKeyConstraintViolation)) => {
        println!("Cannot delete: user has posts");
    }
    _ => {}
}

// Delete posts first, then user
database.delete::<Post>(
    DeleteBehavior::Restrict,
    Some(Filter::eq("author_id", Value::Uint32(1.into()))),
)?;

// Now user can be deleted
database.delete::<User>(
    DeleteBehavior::Restrict,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
)?;
}

Use when: You want to prevent accidental data loss. The caller must explicitly handle related records.

Cascade

Behavior: Delete all records that reference this one (recursively).

#![allow(unused)]
fn main() {
// Deletes user AND all their posts
database.delete::<User>(
    DeleteBehavior::Cascade,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
)?;
}

Cascade is recursive:

#![allow(unused)]
fn main() {
// Schema:
// User -> Posts -> Comments
// Deleting a user cascades to posts, which cascades to comments

database.delete::<User>(
    DeleteBehavior::Cascade,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
)?;
// User deleted
// All user's posts deleted
// All comments on those posts deleted
}

Use when: Related records have no meaning without the parent (e.g., comments on a deleted post).

Choosing a Delete Behavior

ScenarioRecommended Behavior
User account deletion (remove everything)Cascade
Prevent accidental deletionRestrict
Soft delete patternDon’t delete; use status field
Comments on postsCascade (comments meaningless without post)
Products in ordersRestrict (orders are historical records)

Eager Loading

Eager loading fetches related records in a single query, avoiding N+1 query problems.

Basic Eager Loading

Use .with() to eager load a related table:

#![allow(unused)]
fn main() {
// Load posts with their authors
let query = Query::builder()
    .all()
    .with("users")  // Name of the related table
    .build();

let posts = database.select::<Post>(query)?;

// Each post now has author data available
for post in posts {
    println!("Post '{}' by author_id {}", post.title, post.author_id);
}
}

Multiple Relations

Load multiple related tables:

#![allow(unused)]
fn main() {
// Schema:
// Post -> User (author)
// Post -> Category

let query = Query::builder()
    .all()
    .with("users")
    .with("categories")
    .build();

let posts = database.select::<Post>(query)?;
}

Eager Loading with Filters

Combine eager loading with filters:

#![allow(unused)]
fn main() {
// Load published posts with their authors
let query = Query::builder()
    .filter(Filter::eq("published", Value::Boolean(true)))
    .order_by("created_at", OrderDirection::Descending)
    .limit(10)
    .with("users")
    .build();

let posts = database.select::<Post>(query)?;
}

Cross-Table Queries with Joins

In addition to eager loading, wasm-dbms supports SQL-style joins (INNER, LEFT, RIGHT, FULL) for combining rows from multiple tables into a flat result set. Joins are useful when you need columns from several tables in a single row – for example, listing post titles alongside author names. Unlike eager loading, joins return untyped results via the select_raw path.

See the Querying Guide – Joins section for full details and examples.


Common Patterns

One-to-Many

A user has many posts:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
}

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,
}

// Query all posts by a user
let query = Query::builder()
    .filter(Filter::eq("author_id", Value::Uint32(user_id.into())))
    .build();
let user_posts = database.select::<Post>(query)?;
}

Many-to-Many

Use a junction table for many-to-many relationships:

#![allow(unused)]
fn main() {
// Students and Courses (many-to-many)

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "students"]
pub struct Student {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
}

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "courses"]
pub struct Course {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
}

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "enrollments"]
pub struct Enrollment {
    #[primary_key]
    pub id: Uint32,
    #[foreign_key(entity = "Student", table = "students", column = "id")]
    pub student_id: Uint32,
    #[foreign_key(entity = "Course", table = "courses", column = "id")]
    pub course_id: Uint32,
    pub enrolled_at: DateTime,
}

// Find all courses for a student
let query = Query::builder()
    .filter(Filter::eq("student_id", Value::Uint32(student_id.into())))
    .with("courses")
    .build();
let enrollments = database.select::<Enrollment>(query)?;
}

Self-Referential

A table can reference itself (e.g., categories with parent categories, employees with managers):

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "employees"]
pub struct Employee {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    #[foreign_key(entity = "Employee", table = "employees", column = "id")]
    pub manager_id: Nullable<Uint32>,  // Nullable for top-level employees
}

// Find all employees under a manager
let query = Query::builder()
    .filter(Filter::eq("manager_id", Value::Uint32(manager_id.into())))
    .build();
let direct_reports = database.select::<Employee>(query)?;
}
#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "categories"]
pub struct Category {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    #[foreign_key(entity = "Category", table = "categories", column = "id")]
    pub parent_id: Nullable<Uint32>,  // Nullable for root categories
}

// Find root categories
let query = Query::builder()
    .filter(Filter::is_null("parent_id"))
    .build();
let root_categories = database.select::<Category>(query)?;

// Find children of a category
let query = Query::builder()
    .filter(Filter::eq("parent_id", Value::Uint32(parent_id.into())))
    .build();
let children = database.select::<Category>(query)?;
}

Transactions


Overview

wasm-dbms supports ACID transactions, allowing you to group multiple database operations into a single atomic unit. Either all operations succeed and are committed together, or none of them take effect.

Key features:

  • Atomicity: All operations in a transaction succeed or fail together
  • Consistency: Data integrity constraints are maintained
  • Isolation: Transactions are isolated from each other
  • Durability: Committed changes persist

Transaction Lifecycle

Begin Transaction

Start a new transaction using DbmsContext::begin_transaction():

#![allow(unused)]
fn main() {
use wasm_dbms::prelude::*;

// Begin a new transaction
let tx_id = ctx.begin_transaction();
println!("Started transaction: {}", tx_id);
}

The returned transaction ID is used to create a transactional database instance.

Perform Operations

Create a WasmDbmsDatabase with the transaction ID and perform operations:

#![allow(unused)]
fn main() {
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

// Insert within transaction
database.insert::<User>(user)?;

// Update within transaction
database.update::<User>(update)?;

// Delete within transaction
database.delete::<User>(DeleteBehavior::Restrict, Some(filter))?;

// Select within transaction (sees uncommitted changes)
let users = database.select::<User>(query)?;
}

Note: Operations within a transaction are visible to subsequent operations in the same transaction, but not to other callers until committed.

Commit

Commit the transaction to make all changes permanent:

#![allow(unused)]
fn main() {
// Commit the transaction
database.commit()?;
println!("Transaction committed successfully");
}

After commit:

  • All changes become visible to other callers
  • The transaction ID becomes invalid
  • Changes persist in storage

Rollback

Rollback the transaction to discard all changes:

#![allow(unused)]
fn main() {
// Rollback the transaction
database.rollback()?;
println!("Transaction rolled back");
}

After rollback:

  • All changes within the transaction are discarded
  • The transaction ID becomes invalid
  • The database state is as if the transaction never happened

Who May Use a Transaction

The engine identifies a transaction by its id and nothing else. Any code that holds the id can read and write through it, commit it, or roll it back; DbmsContext does not record who opened it. This keeps the engine free of platform concepts such as users, principals, or tenants.

When transactions cross a trust boundary, for example a canister serving many principals or a host serving many tenants, the application layer that embeds the engine decides who may use an id. The Embedding wasm-dbms guide shows how to keep a ledger from transaction id to identity and check it before every transactional call. Single-tenant programs do not need one.

ACID Properties

Atomicity

All operations in a transaction are treated as a single unit. If any operation fails, the entire transaction can be rolled back:

#![allow(unused)]
fn main() {
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

// First operation succeeds
database.insert::<User>(user1)?;

// Second operation fails (e.g., primary key conflict)
let result = database.insert::<User>(user2_duplicate);

if result.is_err() {
    // Rollback everything - user1 is also discarded
    database.rollback()?;
}
}

Consistency

Transactions maintain data integrity:

  • Primary key uniqueness is enforced
  • Foreign key constraints are checked
  • Validators run on all data
  • Sanitizers are applied
#![allow(unused)]
fn main() {
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

// This will fail if referenced user doesn't exist
let post = PostInsertRequest {
    id: 1.into(),
    title: "My Post".into(),
    author_id: 999.into(),  // Non-existent user
};

let result = database.insert::<Post>(post);
// Returns Err(DbmsError::Query(QueryError::BrokenForeignKeyReference))
}

Isolation

Changes made within a transaction are not visible to other callers until committed:

#![allow(unused)]
fn main() {
// Database A starts a transaction
let tx_id = ctx.begin_transaction();
let mut db_a = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);
db_a.insert::<User>(new_user)?;

// Database B queries - does NOT see the new user
let db_b = WasmDbmsDatabase::oneshot(&ctx, my_schema);
let users = db_b.select::<User>(query)?;
assert!(!users.iter().any(|u| u.id == new_user.id));

// Database A commits
db_a.commit()?;

// Now Database B can see the user
let users = db_b.select::<User>(query)?;
assert!(users.iter().any(|u| u.id == new_user.id));
}

Durability

Committed transactions persist in the provider’s logical memory. With the WASI file provider, writes reach the file during normal provider operations. With WasiKeyValueMemoryProvider, call ctx.flush() after the commit to publish the checkpoint; a transaction commit alone does not guarantee that a fresh component instance sees the data after restart. See the key-value provider guide.


Error Handling

Handling Failures

When an operation fails within a transaction, you should typically rollback:

#![allow(unused)]
fn main() {
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

fn process_order(database: &impl Database) -> Result<(), DbmsError> {
    // Multiple operations that should succeed together
    database.insert::<Order>(order)?;
    database.update::<Inventory>(update)?;
    database.insert::<OrderItem>(item)?;
    Ok(())
}

match process_order(&database) {
    Ok(()) => {
        database.commit()?;
        println!("Order processed successfully");
    }
    Err(e) => {
        database.rollback()?;
        println!("Order failed, rolled back: {:?}", e);
    }
}
}

Transaction Errors

ErrorCause
TransactionNotFoundInvalid transaction ID or transaction already completed
NoActiveTransactionAttempting to commit/rollback without an active transaction
#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{DbmsError, TransactionError};

match database.commit() {
    Ok(()) => println!("Committed"),
    Err(DbmsError::Transaction(TransactionError::NoActiveTransaction)) => {
        println!("No active transaction to commit");
    }
    Err(e) => println!("Other error: {:?}", e),
}
}

Best Practices

1. Keep transactions short

Long-running transactions hold resources and block other operations:

#![allow(unused)]
fn main() {
// GOOD: Prepare data outside transaction
let users_to_insert = prepare_users();

let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);
for user in users_to_insert {
    database.insert::<User>(user)?;
}
database.commit()?;

// BAD: Doing expensive work inside transaction
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);
for raw_data in large_dataset {
    let user = expensive_parsing(raw_data);  // Don't do this in transaction
    database.insert::<User>(user)?;
}
database.commit()?;
}

2. Always handle rollback

Ensure transactions are either committed or rolled back:

#![allow(unused)]
fn main() {
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

let result = (|| -> Result<(), DbmsError> {
    database.insert::<User>(user1)?;
    database.insert::<User>(user2)?;
    Ok(())
})();

match result {
    Ok(()) => database.commit()?,
    Err(_) => database.rollback()?,
}
}

Group operations that should succeed or fail together:

#![allow(unused)]
fn main() {
// GOOD: Related operations in transaction
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);
database.insert::<Order>(order)?;
database.insert::<Payment>(payment)?;
database.update::<Inventory>(inv_update)?;
database.commit()?;

// BAD: Unrelated operations in transaction (unnecessary)
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);
database.insert::<UserPreferences>(prefs)?;
database.insert::<AuditLog>(log)?;  // Unrelated
database.commit()?;
}

4. Don’t mix transactional and non-transactional operations

#![allow(unused)]
fn main() {
let tx_id = ctx.begin_transaction();
let mut database = WasmDbmsDatabase::from_transaction(&ctx, my_schema, tx_id);

// GOOD: All operations use the transaction
database.insert::<Order>(order)?;
database.insert::<OrderItem>(item)?;

// BAD: Mixing transaction and non-transaction
let oneshot = WasmDbmsDatabase::oneshot(&ctx, my_schema);
database.insert::<Order>(order)?;
oneshot.insert::<AuditLog>(log)?;  // Not in transaction!
}

Examples

Bank Transfer

Transfer money between accounts atomically:

#![allow(unused)]
fn main() {
fn transfer(
    ctx: &DbmsContext<impl MemoryProvider>,
    from_account: u32,
    to_account: u32,
    amount: Decimal,
) -> Result<(), DbmsError> {
    let tx_id = ctx.begin_transaction();
    let mut database = WasmDbmsDatabase::from_transaction(ctx, my_schema, tx_id);

    // Deduct from source account
    let deduct = AccountUpdateRequest::builder()
        .decrease_balance(amount)
        .filter(Filter::eq("id", Value::Uint32(from_account.into())))
        .build();
    database.update::<Account>(deduct)?;

    // Add to destination account
    let add = AccountUpdateRequest::builder()
        .increase_balance(amount)
        .filter(Filter::eq("id", Value::Uint32(to_account.into())))
        .build();
    database.update::<Account>(add)?;

    // Record the transfer
    let transfer_record = TransferInsertRequest {
        id: Uuid::new_v4().into(),
        from_account: from_account.into(),
        to_account: to_account.into(),
        amount,
        timestamp: DateTime::now(),
    };
    database.insert::<Transfer>(transfer_record)?;

    // Commit atomically
    database.commit()?;
    Ok(())
}
}

Order Processing

Process an order with inventory update:

#![allow(unused)]
fn main() {
fn process_order(
    ctx: &DbmsContext<impl MemoryProvider>,
    order: OrderInsertRequest,
    items: Vec<OrderItemInsertRequest>,
) -> Result<u32, Box<dyn std::error::Error>> {
    let tx_id = ctx.begin_transaction();
    let mut database = WasmDbmsDatabase::from_transaction(ctx, my_schema, tx_id);

    // Insert the order
    database.insert::<Order>(order.clone())?;

    // Insert order items and update inventory
    for item in items {
        // Insert order item
        database.insert::<OrderItem>(item.clone())?;

        // Decrease inventory
        let inv_update = InventoryUpdateRequest::builder()
            .decrease_quantity(item.quantity)
            .filter(Filter::eq("product_id", Value::Uint32(item.product_id)))
            .build();

        let updated = database.update::<Inventory>(inv_update)?;

        if updated == 0 {
            // Product not in inventory, rollback
            database.rollback()?;
            return Err("Product not found in inventory".into());
        }
    }

    // All successful, commit
    database.commit()?;
    Ok(order.id.into())
}
}

SQL


Overview

The wasm-dbms-sql crate lets you read and write a wasm-dbms database with SQL text instead of the query builder:

#![allow(unused)]
fn main() {
let result = engine.execute(
    &ctx,
    None,
    "SELECT name, email FROM users WHERE age >= ? ORDER BY name",
    &[Value::from(18u8)],
)?;
}

It is useful when:

  • you already know SQL and want to explore or debug the data;
  • the query is easier to read as text than as a chain of builder calls;
  • the statement comes from outside your program, such as an admin tool.

SQL covers SELECT (with joins, DISTINCT, aggregates, and GROUP BY), INSERT, UPDATE, DELETE, and transactions. It does not change the schema: tables are still defined with #[derive(Table)]. Every detail of the dialect is in the SQL Reference.

SQL support is a separate crate. Programs that do not depend on it do not pay for it in binary size.


Setup

Dependencies

[dependencies]
wasm-dbms = "0.9"
wasm-dbms-api = "0.9"
wasm-dbms-memory = "0.9"
wasm-dbms-sql = "0.9"

wasm-dbms-sql turns on the sql feature of wasm-dbms and wasm-dbms-api by itself. That feature adds the SqlResult and SqlError types to wasm_dbms_api::prelude.

Create the Engine

The engine needs your database schema, and the schema must be Clone. A schema is normally a unit struct, so deriving Clone is enough:

#![allow(unused)]
fn main() {
use wasm_dbms::prelude::*;
use wasm_dbms_api::prelude::*;
use wasm_dbms_memory::prelude::HeapMemoryProvider;
use wasm_dbms_sql::SqlEngine;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    pub email: Nullable<Text>,
    pub age: Uint8,
}

#[derive(Clone, DatabaseSchema)]
#[tables(User = "users")]
pub struct MySchema;

let ctx = DbmsContext::new(HeapMemoryProvider::default());
MySchema::register_tables(&ctx)?;

let engine = SqlEngine::new(MySchema);
}

Where to Keep the Engine

The engine holds nothing but the schema. Open transactions live in the DbmsContext, so an engine created for a single call and an engine kept for the life of the process behave the same. The engine does not borrow the context, so both can be stored side by side:

#![allow(unused)]
fn main() {
thread_local! {
    static DBMS: DbmsContext<MyMemoryProvider> = DbmsContext::new(MyMemoryProvider::default());
    static SQL: SqlEngine<MySchema> = SqlEngine::new(MySchema);
}

fn run_sql(tx: Option<TransactionId>, sql: &str, params: &[Value]) -> Result<SqlResult, SqlError> {
    DBMS.with(|ctx| SQL.with(|engine| engine.execute(ctx, tx, sql, params)))
}
}

Running Statements

#![allow(unused)]
fn main() {
pub fn execute(
    &self,
    ctx: &DbmsContext<M>,
    tx: Option<TransactionId>,
    sql: &str,
    params: &[Value],
) -> Result<SqlResult, SqlError>
}
ArgumentMeaning
ctxThe database to run against
txNone to apply the statement on its own, Some(id) to run it inside id
sqlOne SQL statement, with an optional trailing ;
paramsOne value per ? placeholder, in order

tx is a transaction id returned by BEGIN or by DbmsContext::begin_transaction. The engine does not check who passes an id: see Transaction Ownership when statements come from more than one user.

Parameters

Write ? where a value goes and pass the values separately:

#![allow(unused)]
fn main() {
engine.execute(
    &ctx,
    None,
    "INSERT INTO users (id, name, email, age) VALUES (?, ?, ?, ?)",
    &[
        Value::from(1u32),
        Value::from("Alice"),
        Value::Null,
        Value::from(30u8),
    ],
)?;
}

Always pass values that come from users as parameters. A parameter is data: it is never read as SQL, so it cannot change what the statement does. Building the SQL text with format! does not have that guarantee.

Parameters do not need the exact type of the column. An integer of any width fits any integer column if the number is in range, and a text value is converted the same way as a string literal.

Reading Results

execute returns a SqlResult:

#![allow(unused)]
fn main() {
match engine.execute(&ctx, None, sql, &[])? {
    SqlResult::Rows(rows) => {
        for row in rows {
            for (column, value) in row {
                println!("{name} = {value:?}", name = column.name);
            }
        }
    }
    SqlResult::RowsAffected(count) => println!("{count} rows written"),
    SqlResult::TxBegin(tx) => println!("transaction {tx} started"),
    SqlResult::TxCommit | SqlResult::TxRollback => {}
}
}

Each row has one (JoinColumnDef, Value) pair per selected column, in the order of the select list. column.name is the column name, or the alias given with AS. For a join, column.table holds the table the column comes from.


Queries

Filtering and Sorting

SELECT id, name
FROM users
WHERE (age < 18 OR age > 65)
  AND email IS NOT NULL
  AND name LIKE 'A%'
ORDER BY age DESC, name
LIMIT 10 OFFSET 20;

LIMIT and OFFSET accept values through 4294967295 on every target. Larger values are rejected consistently by native and WASM builds.

WHERE supports =, !=, <, <=, >, >=, IN, LIKE, IS NULL, and their negations, combined with AND, OR, NOT, and parentheses. The left side of a test is always a column, and the right side a value.

SELECT DISTINCT removes duplicate rows:

SELECT DISTINCT city FROM users ORDER BY city;

Joins

SELECT u.name, p.title AS headline
FROM users AS u
JOIN posts AS p ON u.id = p.user_id
WHERE p.published = TRUE
ORDER BY p.title;

JOIN, LEFT JOIN, RIGHT JOIN, and FULL JOIN are supported. The ON condition is one equality between a column of the joined table and a column of a table before it. When two tables have a column with the same name, qualify it with the table name or alias.

The two ON columns must have the same underlying type, even if their nullability differs. Incompatible types produce SqlError::TypeMismatch. Null join keys never match, including two NULL values.

Aggregates

SELECT category, COUNT(*) AS items, SUM(price) AS revenue
FROM sales
WHERE price > 0
GROUP BY category
HAVING COUNT(*) >= 5
ORDER BY revenue DESC
LIMIT 3;

COUNT, SUM, AVG, MIN, and MAX are available. SUM and AVG return a Decimal, and COUNT a Uint64. Aggregate queries read a single table: they cannot be combined with a join.


Writes

INSERT INTO users (id, name, age) VALUES (2, 'Bob', 25);

UPDATE users SET name = 'Robert', email = 'bob@example.com' WHERE id = 2;

DELETE FROM users WHERE id = 2;

Each write returns SqlResult::RowsAffected with the number of rows written.

UPDATE and DELETE must have a WHERE clause. Without one the statement is rejected with SqlError::MissingWhereClause and nothing changes. This protects against wiping a table by accident.

DELETE fails when another table still references the row. Add CASCADE to delete the referencing rows as well:

DELETE FROM users WHERE id = 1 CASCADE;

Writes go through the same sanitizers, validators, and integrity checks as the typed API.


Transactions

BEGIN, COMMIT, and ROLLBACK group several statements into one unit. BEGIN returns the id of the new transaction; every later statement of the unit, including COMMIT or ROLLBACK, passes that id:

#![allow(unused)]
fn main() {
let SqlResult::TxBegin(tx) = engine.execute(&ctx, None, "BEGIN", &[])? else {
    unreachable!("BEGIN returns TxBegin");
};

let transfer = (|| {
    engine.execute(&ctx, Some(tx), "UPDATE accounts SET balance = ? WHERE id = ?", &[
        Value::from(50u64),
        Value::from(1u32),
    ])?;
    engine.execute(&ctx, Some(tx), "UPDATE accounts SET balance = ? WHERE id = ?", &[
        Value::from(150u64),
        Value::from(2u32),
    ])
})();

match transfer {
    Ok(_) => engine.execute(&ctx, Some(tx), "COMMIT", &[])?,
    Err(error) => {
        engine.execute(&ctx, Some(tx), "ROLLBACK", &[])?;
        return Err(error);
    }
};
}

What to know:

  • BEGIN is sent without an id and returns the new id in SqlResult::TxBegin. Sending BEGIN with an id fails with SqlError::TransactionAlreadyActive.
  • Statements sent with the id run inside the transaction and see its uncommitted writes. Statements sent without an id are applied immediately and do not see them.
  • Several transactions can be open at once. Each is addressed by its own id and does not see the writes of the others.
  • A statement that fails does not close the transaction. Send ROLLBACK to discard it, as in the example above.
  • COMMIT applies all changes together. If it fails, for example because another statement took the same primary key in the meantime, nothing is applied and the transaction is closed. A later COMMIT with the same id fails with SqlError::Runtime(DbmsError::Query(QueryError::TransactionNotFound)).
  • COMMIT or ROLLBACK without an id fails with SqlError::NoActiveTransaction.
  • ctx.has_transaction(&tx) tells whether a transaction is still open.

The ids are shared with the typed API: a transaction opened with ctx.begin_transaction() can run SQL statements, and one opened with BEGIN can be passed to WasmDbmsDatabase::from_transaction. The engine does not check who sends an id; a program that serves several users keeps its own ledger, as described in Transaction Ownership.


Dates, UUIDs, and Other Types

SQL has literals for numbers, strings, booleans, and NULL. Other values are written as strings and converted by the type of the column:

INSERT INTO events (day, at, token, payload, meta)
VALUES ('2026-04-24', '2026-04-24T10:30:00Z',
        '550e8400-e29b-41d4-a716-446655440000', 'deadbeef',
        '{"kind": "signup"}');

SELECT * FROM events WHERE day >= '2026-01-01';
Column typeWritten as
Date'2026-04-24'
DateTime'2026-04-24T10:30:00Z'
Uuid'550e8400-e29b-41d4-a716-446655440000'
BlobHexadecimal: 'deadbeef'
JsonJSON text: '{"kind": "signup"}'

The same values can be passed as typed parameters (Value::Date, Value::Uuid, and so on), which is the only way to pass a custom data type.

A value that does not fit its column is reported before anything is written, for example 300 for a Uint8 column or '2026-02-30' for a Date column.


Error Handling

execute returns a SqlError:

#![allow(unused)]
fn main() {
match engine.execute(&ctx, None, sql, params) {
    Ok(result) => { /* ... */ }
    Err(SqlError::Parse { line, col, msg }) => {
        println!("syntax error at {line}:{col}: {msg}");
    }
    Err(SqlError::UnknownTable(table)) => println!("no table named {table}"),
    Err(SqlError::UnknownColumn { table, column }) => {
        println!("table {table} has no column {column}");
    }
    Err(SqlError::Runtime(DbmsError::Query(QueryError::PrimaryKeyConflict))) => {
        println!("that key already exists");
    }
    Err(other) => println!("{other}"),
}
}

Problems in the statement itself (syntax, unknown names, values of the wrong type) are found before the database is touched. Errors raised by the database while running the statement, such as constraint violations, are wrapped in SqlError::Runtime. All variants are listed in the Errors Reference.


SQL and the Typed API

SQL and the typed API work on the same data and can be mixed freely: a row inserted with SQL is returned by database.select::<User>(...), and the other way round.

Some features are only available through the typed API:

Custom Data Types


Overview

wasm-dbms ships with a set of built-in data types that cover the most common use cases. When your domain requires types that go beyond those built-ins, you can define custom data types.

Custom data types let you store any Rust type – enums, newtypes, structs – inside your tables. The DBMS engine stores them as opaque bytes internally and uses a type tag string to identify each custom type.

When to use custom types:

  • Domain-specific enums (e.g., Priority, Status, Role)
  • Composite value objects (e.g., Address, Coordinates)
  • Newtypes that wrap primitives with domain meaning (e.g., Email(String))

Defining a Custom Type

Creating a custom type requires four steps:

  1. Define the type with the required derives
  2. Implement Display
  3. Implement Encode (binary serialization)
  4. Implement DataType and derive CustomDataType

Step 1: Define the Type

Your type must derive or implement several traits. For enums, all must be implemented manually or derived:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
pub enum Priority {
    #[default]
    Low,
    Medium,
    High,
}
}

For structs, the same traits are required:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
pub struct Address {
    pub street: String,
    pub city: String,
    pub zip: String,
}
}

Required traits:

TraitPurpose
CloneCloning values
DebugDebug formatting
PartialEq, EqEquality comparison
PartialOrd, OrdOrdering (for sorting and range filters)
HashHashing (for hash-based lookups)
DefaultDefault value construction
Serialize, DeserializeSerde serialization
DisplayHuman-readable display (see Step 2)
EncodeBinary encoding for storage (see Step 3)

Note: For Internet Computer usage, see the ic-dbms documentation.

Step 2: Implement Display

The Display implementation provides a human-readable representation used for logging and diagnostics:

#![allow(unused)]
fn main() {
use std::fmt;

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "low"),
            Priority::Medium => write!(f, "medium"),
            Priority::High => write!(f, "high"),
        }
    }
}

impl fmt::Display for Address {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}, {} {}", self.street, self.city, self.zip)
    }
}
}

Step 3: Implement Encode

The Encode trait defines how your type is serialized to and from bytes for memory storage. Enums require a manual implementation; for structs, you can use #[derive(Encode)].

Enum (manual implementation):

#![allow(unused)]
fn main() {
use std::borrow::Cow;

use wasm_dbms_api::prelude::*;

impl Encode for Priority {
    const SIZE: DataSize = DataSize::Fixed(1);
    const ALIGNMENT: PageOffset = DEFAULT_ALIGNMENT;

    fn encode(&self) -> Cow<'_, [u8]> {
        Cow::Owned(vec![match self {
            Priority::Low => 0,
            Priority::Medium => 1,
            Priority::High => 2,
        }])
    }

    fn decode(data: Cow<[u8]>) -> MemoryResult<Self> {
        match data[0] {
            0 => Ok(Priority::Low),
            1 => Ok(Priority::Medium),
            2 => Ok(Priority::High),
            other => Err(MemoryError::DecodeError(DecodeError::TryFromSliceError(
                format!("invalid Priority byte: {other}"),
            ))),
        }
    }

    fn size(&self) -> MSize {
        1
    }
}
}

Struct (derive macro):

The #[derive(Encode)] macro works for structs whose fields all implement Encode. Since String does not implement Encode but Text does, use wasm-dbms types for the struct fields:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(
    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, Encode,
)]
pub struct Address {
    pub street: Text,
    pub city: Text,
    pub zip: Text,
}
}

A struct without fields (struct Empty {} or struct Empty;) is also supported. It encodes to zero bytes: SIZE is DataSize::Fixed(0) and ALIGNMENT is 1. The alignment is also 1 for any fixed-size struct whose total size is 0, because storage divides offsets by the alignment.

Key Encode concepts:

ConstantDescription
DataSize::Fixed(n)Type always encodes to exactly n bytes
DataSize::DynamicEncoded size varies per value
DEFAULT_ALIGNMENTDefault memory page alignment (32 bytes)

Step 4: Implement DataType and Derive CustomDataType

Finally, implement the DataType marker trait and derive CustomDataType with a unique type tag:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

impl DataType for Priority {}

// Manual CustomDataType implementation for enums
impl CustomDataType for Priority {
    const TYPE_TAG: &'static str = "priority";
}

// Manual From<Priority> for Value implementation
impl From<Priority> for Value {
    fn from(val: Priority) -> Value {
        Value::Custom(CustomValue {
            type_tag: <Priority as CustomDataType>::TYPE_TAG.to_string(),
            encoded: Encode::encode(&val).into_owned(),
            display: val.to_string(),
        })
    }
}
}

For structs, you can use the CustomDataType derive macro instead of the manual implementation above:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

impl DataType for Address {}

#[derive(CustomDataType)]
#[type_tag = "address"]
pub struct Address {
    // ...
}
}

The #[derive(CustomDataType)] macro generates both the CustomDataType trait implementation and the From<T> for Value conversion. For enums, you must write these implementations manually.

Type tag rules:

  • Must be unique across all custom types in your database
  • Must be stable across upgrades (changing it makes existing data unreadable)
  • Use lowercase, descriptive names (e.g., "priority", "address", "role")

Using Custom Types in Tables

The custom_type Attribute

To use a custom type in a table, annotate the field with #[custom_type]:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "tasks"]
pub struct Task {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    #[custom_type]
    pub priority: Priority,
    #[custom_type]
    pub address: Address,
}
}

Without the #[custom_type] attribute, the Table macro won’t know how to handle your type and compilation will fail.

Nullable Custom Types

Custom types can be wrapped in Nullable<T> for optional fields:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "tasks"]
pub struct Task {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    #[custom_type]
    pub priority: Nullable<Priority>,
}
}

When Nullable::Null, the value is stored as Value::Null. When Nullable::Value(v), it is stored as Value::Custom(...).


Filtering and Querying

To filter on custom type fields, construct a Value::Custom with the appropriate CustomValue:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

// Create a filter for Priority::High
let high_priority = Priority::High;
let filter = Filter::eq("priority", high_priority.into());

// You can also construct the Value manually
let filter = Filter::eq("priority", Value::Custom(CustomValue {
    type_tag: "priority".to_string(),
    encoded: Encode::encode(&Priority::High).into_owned(),
    display: "high".to_string(),
}));
}

To extract a custom type from a Value:

#![allow(unused)]
fn main() {
let value: Value = Priority::High.into();

// Get the raw CustomValue
if let Some(cv) = value.as_custom() {
    println!("type: {}, display: {}", cv.type_tag, cv.display);
}

// Decode into the concrete type
if let Some(priority) = value.as_custom_type::<Priority>() {
    println!("Priority: {priority}");
}
}

Ordering Contract

Custom types support all filter operations: Eq, Ne, In, Gt, Lt, Ge, Le.

For equality filters (Eq, Ne, In), the only requirement is that the Encode implementation produces canonical output – the same value always encodes to the same bytes.

For range filters (Gt, Lt, Ge, Le) and ORDER BY, the encoding must be order-preserving: if a < b according to Ord, then a.encode() < b.encode() lexicographically. This is because the DBMS compares custom values by their encoded bytes.

Example of order-preserving encoding:

The Priority enum above encodes Low = 0, Medium = 1, High = 2. Since Low < Medium < High in the Ord implementation and [0] < [1] < [2] lexicographically, range filters work correctly.

Warning: If your encoding is not order-preserving, equality filters will still work, but range filters and sorting will produce incorrect results.


Examples

Enum: Priority

A complete example of a custom enum type used in a table:

#![allow(unused)]
fn main() {
use std::borrow::Cow;
use std::fmt;

use serde::{Deserialize, Serialize};
use wasm_dbms_api::prelude::*;

// 1. Define the type
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
pub enum Priority {
    #[default]
    Low,
    Medium,
    High,
}

// 2. Implement Display
impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "low"),
            Priority::Medium => write!(f, "medium"),
            Priority::High => write!(f, "high"),
        }
    }
}

// 3. Implement Encode (manual for enums)
impl Encode for Priority {
    const SIZE: DataSize = DataSize::Fixed(1);
    const ALIGNMENT: PageOffset = DEFAULT_ALIGNMENT;

    fn encode(&self) -> Cow<'_, [u8]> {
        Cow::Owned(vec![match self {
            Priority::Low => 0,
            Priority::Medium => 1,
            Priority::High => 2,
        }])
    }

    fn decode(data: Cow<[u8]>) -> MemoryResult<Self> {
        match data[0] {
            0 => Ok(Priority::Low),
            1 => Ok(Priority::Medium),
            2 => Ok(Priority::High),
            other => Err(MemoryError::DecodeError(DecodeError::TryFromSliceError(
                format!("invalid Priority byte: {other}"),
            ))),
        }
    }

    fn size(&self) -> MSize {
        1
    }
}

// 4. Implement DataType + CustomDataType + From<Priority> for Value
impl DataType for Priority {}

impl CustomDataType for Priority {
    const TYPE_TAG: &'static str = "priority";
}

impl From<Priority> for Value {
    fn from(val: Priority) -> Value {
        Value::Custom(CustomValue {
            type_tag: <Priority as CustomDataType>::TYPE_TAG.to_string(),
            encoded: Encode::encode(&val).into_owned(),
            display: val.to_string(),
        })
    }
}

// Use in a table
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "tasks"]
pub struct Task {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    #[custom_type]
    pub priority: Priority,
}
}

Struct: Address

A complete example of a custom struct type. Structs can use #[derive(Encode)] and #[derive(CustomDataType)]:

#![allow(unused)]
fn main() {
use std::fmt;

use serde::{Deserialize, Serialize};
use wasm_dbms_api::prelude::*;

// 1. Define the type with Encode and CustomDataType derives
#[derive(
    Debug,
    Clone,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    Default,
    Serialize,
    Deserialize,
    Encode,
    CustomDataType,
)]
#[type_tag = "address"]
pub struct Address {
    pub street: Text,
    pub city: Text,
    pub zip: Text,
}

// 2. Implement Display
impl fmt::Display for Address {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}, {} {}",
            self.street.as_str(),
            self.city.as_str(),
            self.zip.as_str(),
        )
    }
}

// 3. Implement DataType
impl DataType for Address {}

// Use in a table
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "customers"]
pub struct Customer {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    #[custom_type]
    pub address: Address,
}
}

Schema Migrations

For the full type and API reference (snapshot format, op enum, error variants), see the Migrations Reference.


Overview

A migration in wasm-dbms is the process of bringing the on-disk data layout into agreement with the schema your binary was compiled against. The framework persists a TableSchemaSnapshot for every table on disk and hashes them into a single schema_hash. On boot, the DBMS recomputes the hash from the compiled schema and compares it. If they differ, the database enters drift state and refuses CRUD until you call migrate(policy).

Migrations are:

  • Forward-only. Failed migrations roll back to the pre-migration state, but the framework provides no path from a newer snapshot to an older compiled schema.
  • Explicit. The DBMS never auto-migrates on init. The operator decides when (and whether) to run them.
  • Atomic. Every op runs inside a single journaled session — either every byte change commits, or none does.
  • Pre-flighted. Each plan is validated against the current data before any page is touched. Errors here cost nothing.

When You Need a Migration

Drift fires whenever the encoded snapshot of any compiled table differs from the snapshot stored on disk. In practice, that means any of:

  • Adding, removing, or renaming a struct that derives Table.
  • Adding, removing, or renaming a field on such a struct.
  • Changing a field’s type (e.g. Uint32 → Uint64, or Text → custom enum).
  • Toggling #[primary_key], #[unique], #[autoincrement], Nullable<T>, or #[foreign_key(...)].
  • Adding or removing an #[index] (single-column or grouped).
  • Bumping #[alignment = N].

Never trigger drift:

  • Adding #[validate(...)], #[sanitizer(...)], or #[default = ...] on its own (sanitizer/validator are runtime-only; #[default] is migration metadata that lives in the snapshot but is consulted by the planner, not by the drift hash for unrelated changes).
  • Reordering doc comments or Debug derives.
  • Changing the table’s Rust struct name without changing #[table = "..."].

The Workflow

For most schema changes, the loop is:

  1. Edit the schema in your #[derive(Table)] structs.
  2. Build and deploy the new binary.
  3. Inspect drift. Call dbms.has_drift()?. Skip if false.
  4. Plan. Call dbms.pending_migrations() and review the Vec<MigrationOp>.
  5. Apply. Call dbms.migrate(policy) once the plan looks right.

The remaining sections walk through the common shapes of step 1 and the policy choices for step 5.


Adding a Column

Nullable Columns

Easiest case. The new column is implicitly NULL for every existing row.

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,

    pub bio: Nullable<Text>, // NEW — no further work needed
}
}

Plan output:

AddColumn { table: "users", column: ColumnSnapshot { name: "bio", nullable: true, default: None, ... } }

migrate(MigrationPolicy::default()) applies it cleanly.

Non-Nullable Columns with a Static Default

If the new column is NOT NULL, the planner needs a default value to backfill existing rows. The cheapest way is the #[default = ...] attribute:

#![allow(unused)]
fn main() {
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,

    #[default = 0]
    pub login_count: Uint32,
}
}

The expression must convert into the column’s Value variant via From/Into. Examples:

#![allow(unused)]
fn main() {
#[default = 0]                                pub login_count: Uint32,
#[default = false]                            pub is_admin: Boolean,
#[default = ""]                               pub locale: Text,
#[default = MyCustomEnum::Default]            pub status: MyCustomEnum,  // requires #[custom_type]
}

Non-Nullable Columns with a Dynamic Default

Sometimes the default depends on runtime context (e.g. derived from another column, or generated by a hash). Mark the table #[migrate] and override Migrate::default_value:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "events"]
#[migrate]
pub struct Event {
    #[primary_key]
    pub id: Uint32,
    pub kind: Text,

    pub severity: Uint8, // NEW
}

impl Migrate for Event {
    fn default_value(column: &str) -> Option<Value> {
        match column {
            "severity" => Some(Value::Uint8(Uint8(1))), // medium severity by default
            _ => None,
        }
    }
}
}

Returning None here falls back to the #[default] attribute. Returning None from both produces MigrationError::DefaultMissing.

Note: without #[migrate], the Table macro emits an empty impl Migrate for T {} for you. Adding a hand-written impl on top of it would be a duplicate.


Renaming a Column

A naive rename — change the field name and ship — looks to the planner like a DropColumn followed by an AddColumn. That destroys the data. Use #[renamed_from(...)] to tell the planner the rename history:

#![allow(unused)]
fn main() {
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[renamed_from("name", "username")]
    pub full_name: Text,
}
}

The planner walks the slice in order: it first looks for a stored column named name; if that misses, it tries username. The first hit emits RenameColumn { old, new: "full_name" } and the column’s data carries over intact.

Multiple renames across releases: keep older entries at the tail. If you renamed username → name in v2 and name → full_name in v3, list ["name", "username"] so a v1-installed database upgrading directly to v3 still finds its column.


Changing a Column Type

Compatible Widening

The framework auto-widens these without user code:

From → ToSemantics
IntN → IntM, M > Nsign-extend
UintN → UintM, M > Nzero-extend
UintN → IntM, M > Nzero-extend into signed
Float32 → Float64widen

Just edit the field type and migrate. Plan output is WidenColumn { ... }.

Custom Transform

Anything else — narrowing, sign flip, int↔float, int↔text, custom enum reshape — needs a transform_column impl:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "events"]
#[migrate]
pub struct Event {
    #[primary_key]
    pub id: Uint32,

    pub severity: Uint8, // was: Text("low" | "medium" | "high")
}

impl Migrate for Event {
    fn default_value(_column: &str) -> Option<Value> {
        None
    }

    fn transform_column(column: &str, old: Value) -> DbmsResult<Option<Value>> {
        match column {
            "severity" => match old {
                Value::Text(Text(s)) => match s.as_str() {
                    "low" => Ok(Some(Value::Uint8(Uint8(1)))),
                    "medium" => Ok(Some(Value::Uint8(Uint8(5)))),
                    "high" => Ok(Some(Value::Uint8(Uint8(9)))),
                    other => Err(DbmsError::Migration(MigrationError::TransformAborted {
                        table: "events".into(),
                        column: column.into(),
                        reason: format!("unknown severity `{other}`"),
                    })),
                },
                _ => Ok(None),
            },
            _ => Ok(None),
        }
    }
}
}

Return values:

  • Ok(Some(v)) → store v. The planner emits TransformColumn { old_type: Text, new_type: Uint8 }.
  • Ok(None) → no transform. The framework errors with MigrationError::IncompatibleType unless a widening already applies.
  • Err(_) → abort the migration. The journal rolls back.

Dropping a Column or Table

DropColumn and DropTable are destructive. The default MigrationPolicy::default() refuses them:

#![allow(unused)]
fn main() {
let plan = dbms.pending_migrations()?;   // shows DropTable / DropColumn ops
let result = dbms.migrate(MigrationPolicy::default());
// → Err(DbmsError::Migration(MigrationError::DestructiveOpDenied { op: "DropColumn" }))
}

Opt in explicitly:

#![allow(unused)]
fn main() {
dbms.migrate(MigrationPolicy { allow_destructive: true })?;
}

Tip: keep allow_destructive: false in the standard upgrade path and set it to true only when the operator has manually inspected pending_migrations() output. A typo in #[table = "..."] looks identical to a deliberate drop in the diff.


Tightening Constraints

A tightening is any AlterColumn change in the restrictive direction:

  • nullable: true → nullable: false
  • unique: false → unique: true
  • adding a #[foreign_key(...)]

Tightenings run after all data rewrites (relaxations, widenings, transforms, adds). The planner validates existing rows against the new constraint at this step. Any violation produces MigrationError::ConstraintViolation { table, column, reason } and rolls back the entire session.

Recommended pattern (split across two releases):

  1. Release N — relax + backfill:

    #![allow(unused)]
    fn main() {
    pub email: Nullable<Text>,   // still nullable
    }

    Backfill NULL rows manually or via a one-off update before shipping the next release.

  2. Release N+1 — tighten:

    #![allow(unused)]
    fn main() {
    #[unique]
    pub email: Text,             // now NOT NULL + unique
    }

This isolates ConstraintViolation to a release whose cause is obvious.


Adding and Dropping Indexes

Add an #[index] and the planner emits AddIndex. Remove it and you get DropIndex. Composite indexes match by (sorted column list, unique), so changing the group name on a composite index is equivalent to dropping the old one and adding a new one with the same shape.

#![allow(unused)]
fn main() {
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[index] // NEW
    #[unique]
    pub email: Text,
}
}

Index migrations rebuild the B+ tree from scratch, so they scale O(n log n) with row count.


Running Migrations

#![allow(unused)]
fn main() {
use wasm_dbms::prelude::*;
use wasm_dbms_api::prelude::MigrationPolicy;

fn boot<M>(mut dbms: WasmDbmsDatabase<'_, M>) -> DbmsResult<()>
where
    M: MemoryProvider,
{
    if dbms.has_drift()? {
        let plan = dbms.pending_migrations()?;
        eprintln!("schema drift detected, applying {} ops", plan.len());
        for op in &plan {
            eprintln!("  {op:?}");
        }
        dbms.migrate(MigrationPolicy::default())?;
    }
    Ok(())
}
}

migrate is idempotent: when there is no drift, it is a no-op.

For the migration endpoints and upgrade hooks on the Internet Computer, see the ic-dbms documentation.


Inspecting Drift Without Migrating

pending_migrations() is safe to call regardless of drift state and never writes to memory. Use it to:

  • Diff a development branch against production data.
  • Generate a changelog entry from MigrationOp Debug output.
  • Catch unintended drops in CI before the binary ships.
#![allow(unused)]
fn main() {
let plan = dbms.pending_migrations()?;
for op in plan {
    println!("{op:?}");
}
}

Recovering from a Failed Migration

A failed migrate() call rolls back every page touched in the journal session. Stored snapshots, schema_hash, and the in-memory drift flag are not mutated on failure. So after an error:

  • The DBMS stays in drift state.
  • Stored data is byte-identical to its pre-migration state.

Recovery is iterative:

  1. Read the error variant. IncompatibleType, DefaultMissing, ConstraintViolation, DestructiveOpDenied, and TransformAborted each call out the offending table/column/reason.
  2. Fix the cause: add #[default], write a transform_column arm, clean offending rows, or relax the policy.
  3. Redeploy the binary (or just retry migrate if the fix is data-side, not schema-side).

There is no partial-success state to clean up. Either the plan applied in full or it didn’t apply at all.


Testing Migrations

The migration pipeline is testable end-to-end on the heap memory provider:

  1. Register the old schema with a fresh DbmsContext.
  2. Insert representative fixtures.
  3. Drop the context and reopen it with the new schema (no rebuild, since this is just Rust code).
  4. Assert has_drift() == true, inspect pending_migrations(), call migrate(policy).
  5. Read the rows back and assert the expected post-migration state.
#![allow(unused)]
fn main() {
#[test]
fn renames_preserve_data() {
    // v1 schema: column "name"
    let ctx = DbmsContext::new(HeapMemoryProvider::default());
    SchemaV1::register_tables(&ctx).unwrap();
    let mut db = WasmDbmsDatabase::oneshot(&ctx, SchemaV1);
    db.insert::<UserV1>(/* ... */).unwrap();
    drop(db);

    // v2 schema: column renamed to "full_name"
    let mut db = WasmDbmsDatabase::oneshot(&ctx, SchemaV2);
    assert!(db.has_drift().unwrap());
    db.migrate(MigrationPolicy::default()).unwrap();

    let users: Vec<UserV2Record> = db.select::<UserV2>(Query::builder().build()).unwrap();
    assert_eq!(users[0].full_name, Some(/* ... */));
}
}

Round-trip the snapshots through Encode::encode / Encode::decode to confirm the wire format hasn’t shifted.


Common Pitfalls

  • Renaming without #[renamed_from]. The planner has no way to know your intent; it will emit DropColumn + AddColumn and silently lose data the moment allow_destructive: true is set.
  • Adding a non-nullable column without a default. Pre-flight will reject the plan with DefaultMissing. Either provide #[default], override Migrate::default_value, or make the column Nullable<T>.
  • Tightening on dirty data. A nullable: false flip after a release that allowed nulls will fail unless every row already satisfies the constraint. Backfill in a prior release.
  • Reordering DataTypeSnapshot discriminants. The on-disk format depends on the exact tag bytes. Treat the enum as frozen — new variants take fresh tags, removed ones leave a reserved hole.
  • Bumping #[alignment = N]. This changes the on-disk record layout for the table. Until WidenColumn is generalised to handle alignment changes, this requires a manual rewrite. Avoid unless absolutely necessary.
  • Calling migrate before register_tables. The drift hash is computed from the registered set. Always register every table that backs a #[derive(Table)] struct in the compiled binary, even if you don’t expect to write to it this release.

Wasmtime Example

This guide explains how to use wasm-dbms with the WebAssembly Component Model (WIT) and Wasmtime. It walks through the example in crates/wasm-dbms/example/.


Overview

The WebAssembly Component Model defines a standard way for WASM modules to expose typed interfaces using WIT (WebAssembly Interface Types). This example shows how to:

  1. Define a WIT interface for the wasm-dbms CRUD and transaction API
  2. Build a guest WASM component that wraps wasm-dbms behind the WIT interface
  3. Run the guest inside a native Wasmtime host

This approach makes wasm-dbms usable from any Component Model host, not just Rust. The WIT contract at /wit/dbms.wit can be consumed by hosts written in Go, Python, JavaScript, or any language with Component Model tooling.


How It Works

WIT Interface

The WIT definition (/wit/dbms.wit) exposes a database interface with these operations:

  • select — query rows from a table with optional filter, ordering, limit, and offset
  • insert — insert a row into a table, optionally within a transaction
  • update — update rows matching a filter, optionally within a transaction
  • delete — delete rows matching a filter, optionally within a transaction
  • begin-transaction — start a new ACID transaction
  • commit / rollback — finalize or abort a transaction

Values are passed as a value variant type that covers booleans, integers, floats, strings, blobs, and null. Filters are JSON-serialized strings matching the wasm_dbms_api::Filter type.

Decimals, dates, date-times, JSON documents, and UUIDs travel as strings. The guest parses them strictly and returns an invalid-query error for a malformed value, such as 2025-02-30 or {, instead of storing a default or null value:

VariantAccepted form
decimal-valExact decimal notation, for example -12.3450
date-valYYYY-MM-DD, validated against the calendar
datetime-valYYYY-MM-DDTHH:MM:SS, optional .ffffff, then Z or ±HH:MM
json-valAny well-formed JSON document
uuid-valHyphenated form, for example 550e8400-e29b-41d4-a716-446655440000

This raw/dynamic API is intentional: WIT cannot express Rust generics or user-defined table schemas, so type safety is enforced inside the guest by the wasm-dbms engine.

Guest Component

The guest (crates/wasm-dbms/example/guest/) compiles to wasm32-wasip2 and exports the WIT database interface. Internally it:

  1. Initializes a file-backed or key-value DbmsContext lazily on first call; if storage cannot be opened or the tables cannot be registered, the call returns a dbms-error (for example memory-error) instead of trapping, and the next call retries
  2. Registers example tables (users, posts) using #[derive(Table)]
  3. Converts between WIT variant values and wasm-dbms Value types
  4. Dispatches operations through a DatabaseSchema implementation

The bridge layer in lib.rs handles all the type conversions between the WIT boundary and the typed wasm-dbms internals.

Host Binary

The host (crates/wasm-dbms/example/host/) is a native Rust binary using Wasmtime. It:

  1. Creates a Wasmtime engine with Component Model enabled
  2. Creates a fresh, uniquely named directory under the system temporary directory and preopens it as the guest’s root, so the database file never touches existing data
  3. Loads the guest .wasm component and instantiates it
  4. Calls the exported database functions to demonstrate all operations

FileMemoryProvider

The FileMemoryProvider implements the MemoryProvider trait using std::fs file I/O. It provides persistent, file-backed storage so that data survives across invocations.

#![allow(unused)]
fn main() {
use wasm_dbms_memory::prelude::MemoryProvider;

pub struct FileMemoryProvider {
    file: File, // open file handle
    size: u64,  // current size in bytes
    pages: u64, // allocated pages (size / PAGE_SIZE)
}
}

Operations:

  • grow(n) — extends the file by n × 65536 bytes
  • read(offset, buf) — seeks to offset and reads into buffer
  • write(offset, buf) — seeks to offset, writes buffer, and flushes

The provider is initialized with a file path relative to the WASI preopened directory (defaults to wasm-dbms.db).

Note: FileMemoryProvider does not handle concurrent access. It assumes single-writer usage.

Key-Value Guest Variant

The guest also has a key-value feature. Build and test it with:

just test_wasm_dbms_key_value_example
wasm-tools component wit .artifact/wasm-dbms-example-guest-key-value.wasm

This variant opens bucket default and database namespace example. It imports only wasi:keyvalue/store@0.2.0-draft2 and wasi:keyvalue/batch@0.2.0-draft2 for persistence; it does not require a filesystem preopen. Successful WIT operations checkpoint through ctx.flush(). The host must retain the bucket across component restarts, provide durable backing, and coordinate a single writer. See the key-value provider guide for the checkpoint and failure model.


Building and Running

Prerequisites

  • Rust 1.91.1+

  • wasm32-wasip2 target:

    rustup target add wasm32-wasip2
    
  • just command runner

Build

# Build guest + host
just build_wasm_dbms_example

This compiles the guest to wasm32-wasip2 (producing a WASM component at .artifact/wasm-dbms-example-guest.wasm) and builds the native host binary.

Run

just test_wasm_dbms_example

Or run manually:

cargo run --release -p wasm-dbms-example-host -- .artifact/wasm-dbms-example-guest.wasm

The demo inserts users and posts, queries them with filters and ordering, demonstrates transaction commit (data persists) and rollback (data discarded), then removes the directory it created for this run. A wasm-dbms.db file in your current directory is never read, modified, or deleted.


Extending with Custom Tables

To add your own tables to the example:

  1. Define the table in guest/src/schema.rs:

    #![allow(unused)]
    fn main() {
    #[derive(Debug, Table, Clone, PartialEq, Eq)]
    #[table = "comments"]
    pub struct Comment {
        #[primary_key]
        pub id: Uint32,
        pub body: Text,
        #[foreign_key(entity = "Post", table = "posts", column = "id")]
        pub post_id: Uint32,
    }
    }
  2. Add dispatch arms for "comments" in every method of ExampleDatabaseSchema in guest/src/schema.rs (select, insert, update, delete, validate_insert, validate_update, referenced_tables).

  3. Register the table in register_tables():

    #![allow(unused)]
    fn main() {
    ctx.register_table::<Comment>()?;
    }
  4. Update the column lookup in table_columns() (guest/src/lib.rs):

    #![allow(unused)]
    fn main() {
    "comments" => Ok(schema::Comment::columns()),
    }
  5. Register the table name in registered_table_names() (guest/src/lib.rs). The guest resolves caller-provided table names against this finite list, so unknown names are rejected with table-not-found and are never retained:

    #![allow(unused)]
    fn main() {
    fn registered_table_names() -> [&'static str; 3] {
        [
            schema::User::table_name(),
            schema::Post::table_name(),
            schema::Comment::table_name(),
        ]
    }
    }
  6. Rebuild with just build_wasm_dbms_example.


Key Concepts

ConceptDescription
WITWebAssembly Interface Types — a language for defining typed component interfaces
Component ModelThe standard for composing WASM modules with defined imports/exports
wasm32-wasip2Rust compilation target that produces WASM components with WASI Preview 2 support
wit-bindgenGuest-side code generator that creates Rust types from WIT definitions
wasmtime::component::bindgen!Host-side macro that generates Rust types for calling WIT interfaces
DatabaseSchemawasm-dbms trait that dispatches generic operations to concrete table types
FileMemoryProviderFile-backed MemoryProvider implementation for persistent storage

Next Steps

Embedding wasm-dbms


Overview

wasm-dbms is an engine, not a server. It stores tables, runs queries, and keeps transactions, but it does not know who is calling, how calls arrive, or when the process restarts. The application layer that embeds the engine answers those questions. This guide shows what that layer has to do:

  1. pick a MemoryProvider for the target runtime;
  2. own one DbmsContext per database for the life of the process;
  3. open a WasmDbmsDatabase session per call;
  4. run transactions across calls and decide who may use them;
  5. optionally expose SQL next to the typed API;
  6. know what survives a restart.

The Architecture page describes where this layer sits.


Choose a Memory Provider

The engine reads and writes 64 KiB pages through the MemoryProvider trait of wasm-dbms-memory. The provider decides where those pages live:

ProviderWhere the pages liveUse it for
HeapMemoryProviderA Vec<u8> on the heapTests and throwaway databases
WasiMemoryProviderA file opened through WASIWasmtime, Wasmer, WasmEdge, and other WASI hosts
WasiKeyValueMemoryProviderA draft2 key-value bucketHosts with persistent WASI key-value storage
Stable memory provider of ic-dbmsInternet Computer stable memoryCanisters

HeapMemoryProvider ships with wasm-dbms-memory. WasiMemoryProvider is described in the WASI Memory Provider page. The key-value option is described in the WASI Key-Value Memory Provider page. A new runtime needs a new provider; the Memory Management page explains what the engine expects from it.

The key-value provider caches the complete database and needs an explicit ctx.flush() after committed work. Its host must provide draft2 store and batch, durable bucket backing, and single-writer coordination.


Own One Context

DbmsContext owns everything the engine needs: the memory manager, the schema registry, the open transactions, and the journal. Create it once, when the process starts or on the first call, and keep it alive until the process ends. Creating a context per call would reload the schema registry on every call and would lose every open transaction.

The context is single-threaded on purpose: WASM runtimes run one call at a time, so the context uses RefCell instead of locks and is neither Send nor Sync. A thread_local! is the natural home for it:

#![allow(unused)]
fn main() {
use std::cell::RefCell;

use wasm_dbms::prelude::*;
use wasm_dbms_api::prelude::*;

#[derive(Clone, DatabaseSchema)]
#[tables(User = "users")]
pub struct MySchema;

thread_local! {
    static DBMS: RefCell<Option<DbmsContext<MyMemoryProvider>>> = const { RefCell::new(None) };
}

/// Runs `f` on the database, opening it on the first call.
fn with_dbms<F, R>(f: F) -> R
where
    F: FnOnce(&DbmsContext<MyMemoryProvider>) -> R,
{
    DBMS.with(|cell| {
        let mut slot = cell.borrow_mut();
        let ctx = slot.get_or_insert_with(|| {
            let ctx = DbmsContext::new(MyMemoryProvider::default());
            MySchema::register_tables(&ctx).expect("failed to register tables");
            ctx
        });
        f(ctx)
    })
}
}

register_tables writes the schema of every table that is not already registered and is safe to call on every start.


Open a Session per Call

A WasmDbmsDatabase is a short-lived view on the context. It is cheap to create, so open one per call and let it go when the call ends:

  • WasmDbmsDatabase::oneshot(&ctx, MySchema) applies every operation immediately and atomically;
  • WasmDbmsDatabase::from_transaction(&ctx, MySchema, tx) stages every operation in the transaction tx.
#![allow(unused)]
fn main() {
fn insert_user(tx: Option<TransactionId>, user: UserInsertRequest) -> Result<(), DbmsError> {
    with_dbms(|ctx| {
        let database = match tx {
            Some(tx) => WasmDbmsDatabase::from_transaction(ctx, MySchema, tx),
            None => WasmDbmsDatabase::oneshot(ctx, MySchema),
        };
        database.insert::<User>(user)
    })
}
}

from_transaction does not check the id. An operation on an id that is not open fails with DbmsError::Query(QueryError::TransactionNotFound).


Transactions Across Calls

Call-based runtimes cannot keep a Rust value alive between two calls, so the engine keeps transactions in the context and addresses them by TransactionId. An embedder exposes three kinds of entry points:

  1. one that opens a transaction and returns its id;
  2. operations that take an optional id and run inside it when present;
  3. one that commits and one that rolls back a given id.
#![allow(unused)]
fn main() {
fn begin() -> TransactionId {
    with_dbms(|ctx| ctx.begin_transaction())
}

fn commit(tx: TransactionId) -> Result<(), DbmsError> {
    with_dbms(|ctx| WasmDbmsDatabase::from_transaction(ctx, MySchema, tx).commit())
}

fn rollback(tx: TransactionId) -> Result<(), DbmsError> {
    with_dbms(|ctx| WasmDbmsDatabase::from_transaction(ctx, MySchema, tx).rollback())
}
}

A client then calls begin, passes the id to as many operations as it needs, and ends with commit or rollback. The Transactions guide covers what happens inside the engine during each step. commit consumes the transaction whether it succeeds or fails; ctx.has_transaction(&tx) reports whether an id is still open.


Transaction Ownership

The engine identifies a transaction by its id and nothing else. Whoever holds the id can read and write through it, commit it, or roll it back. The engine does not record who opened it, because “who” is a platform concept: a principal on the Internet Computer, a tenant behind a host function, a session of a CLI.

A single-tenant embedder, such as a CLI or a program that serves one user, needs nothing more.

An embedder that exposes transactions across a trust boundary, such as a canister serving many principals or a host serving many tenants, keeps its own ledger from TransactionId to its identity type and checks it before every call that touches a transaction:

  1. Insert on begin. Record the identity of the caller next to the new id.
  2. Check on every transactional call. Before from_transaction, commit, and rollback, look the id up and compare the identity. Report a mismatch the same way as an unknown id, so that an outsider cannot tell whether an id exists.
  3. Remove on commit and rollback, whether or not the engine call succeeded: the engine consumes the transaction in both cases.
  4. Evict closed entries. A transaction can be closed through a path the ledger did not see, for example a typed commit next to a SQL COMMIT. When has_transaction returns false for an id in the ledger, drop the entry.
#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::collections::HashMap;

/// Whoever the runtime says made the current call.
type Identity = Vec<u8>;

thread_local! {
    static OWNERS: RefCell<HashMap<TransactionId, Identity>> = RefCell::new(HashMap::new());
}

fn begin(identity: Identity) -> TransactionId {
    let tx = with_dbms(|ctx| ctx.begin_transaction());
    OWNERS.with(|owners| owners.borrow_mut().insert(tx, identity));
    tx
}

/// Checks that `identity` opened `tx` and that `tx` is still open.
fn authorize(identity: &[u8], tx: TransactionId) -> Result<(), DbmsError> {
    let open = with_dbms(|ctx| ctx.has_transaction(&tx));
    if !open {
        // closed through a path this ledger did not see
        OWNERS.with(|owners| owners.borrow_mut().remove(&tx));
        return Err(DbmsError::Query(QueryError::TransactionNotFound));
    }
    let owned = OWNERS.with(|owners| {
        owners
            .borrow()
            .get(&tx)
            .is_some_and(|owner| owner.as_slice() == identity)
    });
    if owned {
        Ok(())
    } else {
        Err(DbmsError::Query(QueryError::TransactionNotFound))
    }
}

fn insert_user(
    identity: &[u8],
    tx: Option<TransactionId>,
    user: UserInsertRequest,
) -> Result<(), DbmsError> {
    if let Some(tx) = tx {
        authorize(identity, tx)?;
    }
    with_dbms(|ctx| {
        let database = match tx {
            Some(tx) => WasmDbmsDatabase::from_transaction(ctx, MySchema, tx),
            None => WasmDbmsDatabase::oneshot(ctx, MySchema),
        };
        database.insert::<User>(user)
    })
}

fn commit(identity: &[u8], tx: TransactionId) -> Result<(), DbmsError> {
    authorize(identity, tx)?;
    let result = with_dbms(|ctx| WasmDbmsDatabase::from_transaction(ctx, MySchema, tx).commit());
    // the engine consumed the transaction whether or not the commit succeeded
    OWNERS.with(|owners| owners.borrow_mut().remove(&tx));
    result
}
}

rollback follows the same shape as commit. The ledger lives on the heap next to the context; see Restarts and Upgrades for why it must not outlive it.


Add SQL

SqlEngine from wasm-dbms-sql runs SQL text against the same context and uses the same transaction ids. It holds only the schema, so it can live next to the context or be created per call:

#![allow(unused)]
fn main() {
use wasm_dbms_sql::SqlEngine;

fn run_sql(
    identity: &[u8],
    tx: Option<TransactionId>,
    sql: &str,
    params: &[Value],
) -> Result<SqlResult, SqlError> {
    if let Some(tx) = tx {
        authorize(identity, tx)?;
    }
    let result = with_dbms(|ctx| SqlEngine::new(MySchema).execute(ctx, tx, sql, params));
    match &result {
        Ok(SqlResult::TxBegin(tx)) => {
            OWNERS.with(|owners| owners.borrow_mut().insert(*tx, identity.to_vec()));
        }
        Ok(SqlResult::TxCommit | SqlResult::TxRollback) => {
            if let Some(tx) = tx {
                OWNERS.with(|owners| owners.borrow_mut().remove(&tx));
            }
        }
        _ => {}
    }
    result
}
}

A transaction opened with BEGIN can be committed through the typed API, and one opened with ctx.begin_transaction() can run SQL. The SQL guide describes the statement semantics.


Restarts and Upgrades

Two kinds of state exist:

StateWhere it livesAfter a restart or upgrade
Committed rows, indexes, schema registryMemory providerKept
Open transactionsHeapGone
Ownership ledger of the embedderHeapGone

Committed data is written through the memory provider as soon as commit returns, so it survives whatever the provider survives. A transaction that is open when the process stops is lost together with its writes, and so is every ledger entry that points at it. Nothing needs to be saved before a restart or restored after one: the ledger and the open transactions disappear together and stay consistent.

Transaction ids restart from 0 with every new context. A ledger that survived while the context did not would therefore authorize stale ids for transactions it never saw. Keep the ledger in the same heap as the context, and never persist it.


Reference Implementations

  • ic-dbms is the application layer for the Internet Computer: a stable-memory provider, a canister that owns the context, Candid entry points, and a ledger from transaction id to principal.
  • The Wasmtime Example in crates/wasm-dbms/example/ is a single-tenant WASI embedder: a file-backed provider, a thread_local! context, and a WIT interface with begin-transaction, commit, and rollback entry points.

Schema Definition


Overview

wasm-dbms schemas are defined entirely in Rust using derive macros and attributes. Each struct represents a database table, and each field represents a column.

Key concepts:

  • Structs with #[derive(Table)] become database tables
  • Fields become columns with their types
  • Attributes configure primary keys, foreign keys, validation, and more

Warning

The schema snapshot format used for migration detection imposes hard limits on identifier lengths and table shape. Exceeding any of these will cause the snapshot encoder to truncate or panic at runtime:

  • Table name: at most 255 bytes (UTF-8).
  • Column name: at most 255 bytes (UTF-8). Applies to every column, including the primary key and any column referenced by an index or foreign key.
  • Custom data type name: at most 255 bytes (UTF-8).
  • Reserved column name: where_clause cannot be used as a column name, because the generated update request stores its filter in a field with that name.
  • Foreign key target (table name and column name): each at most 255 bytes.
  • Columns per index: at most 255.
  • Columns per table: at most 65,535.
  • Indexes per table: at most 65,535.

Pick short, snake_case identifiers. The 255-byte cap is well above any sensible name length, but binary identifiers or non-ASCII text can blow past it faster than expected because the limit is in bytes, not characters.


Table Definition

Required Derives

Every table struct must have these derives:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
}
}
DeriveRequiredPurpose
TableYesGenerates table schema and related types
CloneYesRequired by the macro system
DebugRecommendedUseful for debugging
PartialEq, EqRecommendedUseful for comparisons in tests

Note: For Internet Computer usage, see the ic-dbms Schema Reference.

Table Attribute

The #[table = "name"] attribute specifies the table name in the database:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "user_accounts"] // Table name in database
pub struct UserAccount {
    // Rust struct name (can differ)
    // ...
}
}

Naming conventions:

  • Use snake_case for table names
  • Table names should be plural (e.g., users, posts, order_items)
  • Keep names short but descriptive

Generic Tables

#[derive(Table)] and #[derive(DatabaseSchema)] accept type parameters and where clauses. The generics are carried over to every generated type and impl, so a table can be generic over a custom data type:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "tagged_items"]
pub struct TaggedItem<T>
where
    T: CustomDataType,
{
    #[primary_key]
    pub id: Uint32,
    #[custom_type]
    pub tag: T,
}

#[derive(DatabaseSchema)]
#[tables(TaggedItem<T> = "tagged_items")]
pub struct TaggedSchema<T>
where
    T: CustomDataType,
{
    _tag: std::marker::PhantomData<T>,
}
}

Type parameters must be 'static, and the generated impls add that bound. Lifetime parameters and const generic parameters are rejected with a compile error.


Column Attributes

Primary Key

Every table must have exactly one primary key:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32, // Primary key
    pub name: Text,
}
}

Primary key rules:

  • Exactly one field must be marked with #[primary_key]
  • Primary keys must be unique across all records
  • Primary keys cannot be null
  • Common types: Uint32, Uint64, Uuid, Text

UUID as primary key:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "orders"]
pub struct Order {
    #[primary_key]
    pub id: Uuid, // UUID primary key
    pub total: Decimal,
}
}

Autoincrement

Automatically generate sequential values for a column on insert:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    #[autoincrement]
    pub id: Uint32, // Automatically assigned 1, 2, 3, ...
    pub name: Text,
}
}

Autoincrement rules:

  • Only integer types are supported: Int8, Int16, Int32, Int64, Uint8, Uint16, Uint32, Uint64
  • The counter starts at zero and increments by one on each insert
  • Each autoincrement column has an independent counter
  • Counters are stored in memory pages, so they persist for as long as the memory provider does
  • When the counter reaches the type’s maximum value, inserts return an AutoincrementOverflow error
  • Deleted records do not recycle their autoincrement values
  • A table can have multiple #[autoincrement] columns

Choosing the right type:

TypeMax Records
Uint32~4.3 billion
Uint64~18.4 quintillion
Int32~2.1 billion
Int64~9.2 quintillion

Tip: Uint64 is recommended for most use cases. Only use smaller types when storage space is critical and you are certain the record count will stay within bounds.

Combining with other attributes:

#![allow(unused)]
fn main() {
#[primary_key]
#[autoincrement]
pub id: Uint64,  // Auto-generated unique primary key
}

Unique

Enforce uniqueness on a column:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[unique]
    pub email: Text, // Must be unique across all rows

    pub name: Text,
}
}

Unique constraint rules:

  • Insert and update operations that would create a duplicate value return a UniqueConstraintViolation error
  • Multiple fields in the same table can each be marked #[unique] independently
  • A #[unique] field automatically gets a B+ tree index – no separate #[index] annotation is needed
  • Primary keys are always unique by definition; you don’t need #[unique] on a #[primary_key] field

Combining with other attributes:

#![allow(unused)]
fn main() {
#[unique]
#[sanitizer(TrimSanitizer)]
#[sanitizer(LowerCaseSanitizer)]
#[validate(EmailValidator)]
pub email: Text,  // Sanitized, validated, then checked for uniqueness
}

Note: Sanitization and validation run before the uniqueness check, so the sanitized value is what gets compared.

Index

Define indexes on columns for faster lookups:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[index]
    pub email: Text, // Single-column index

    pub name: Text,
}
}

The primary key is always an implicit index – you don’t need to add #[index] to it.

Composite indexes:

Use group to group multiple fields into a single composite index:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "products"]
pub struct Product {
    #[primary_key]
    pub id: Uint32,

    #[index(group = "category_brand")]
    pub category: Text,

    #[index(group = "category_brand")]
    pub brand: Text,

    pub name: Text,
}
}

Fields sharing the same group name form a composite index, with columns ordered by field declaration order. In the example above, the composite index covers (category, brand). The primary key can be a member of a composite index.

Queries use a composite index from its first column onward: equality on the leading columns, optionally followed by a range on the next column. A condition on a later column alone cannot use the index. See Composite Indexes.

Indexes with the same column list are created once: an #[index] on the primary key or on a #[unique] field (which already has an implicit index) adds nothing.

Syntax variants:

#![allow(unused)]
fn main() {
// Single-column index
#[index]

// Composite index (group multiple fields by name)
#[index(group = "group_name")]
}

Foreign Key

Define relationships between tables:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "posts"]
pub struct Post {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,

    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,
}
}

Attribute parameters:

ParameterDescription
entityRust struct name of the referenced table
tableTable name (from #[table = "..."])
columnColumn name in the referenced table

The referenced column does not need to be the primary key, but it should be #[unique]. Existence checks, eager loading, delete behaviors and updates all use the declared column; updating it rewrites the referencing rows.

Nullable foreign key:

#![allow(unused)]
fn main() {
#[foreign_key(entity = "User", table = "users", column = "id")]
pub manager_id: Nullable<Uint32>,  // Can be null
}

Self-referential foreign key:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "categories"]
pub struct Category {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,

    #[foreign_key(entity = "Category", table = "categories", column = "id")]
    pub parent_id: Nullable<Uint32>,
}
}

Custom Type

Mark a field as a user-defined custom data type:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "tasks"]
pub struct Task {
    #[primary_key]
    pub id: Uint32,
    #[custom_type]
    pub priority: Priority, // User-defined type
}
}

The #[custom_type] attribute tells the Table macro that this field implements the CustomDataType trait. Without it, the macro won’t know how to serialize and deserialize the field.

Nullable custom types:

#![allow(unused)]
fn main() {
#[custom_type]
pub priority: Nullable<Priority>,  // Optional custom type
}

See the Custom Data Types Guide for how to define custom types.

Sanitizer

Apply data transformations before storage:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[sanitizer(TrimSanitizer)]
    pub name: Text,

    #[sanitizer(LowerCaseSanitizer)]
    #[sanitizer(TrimSanitizer)]
    pub email: Text,

    #[sanitizer(RoundToScaleSanitizer(2))]
    pub balance: Decimal,

    #[sanitizer(ClampSanitizer, min = 0, max = 120)]
    pub age: Uint8,
}
}

Syntax variants:

#![allow(unused)]
fn main() {
// Unit struct (no parameters)
#[sanitizer(TrimSanitizer)]

// Tuple struct (positional parameter)
#[sanitizer(RoundToScaleSanitizer(2))]

// Named fields struct
#[sanitizer(ClampSanitizer, min = 0, max = 100)]
}

See Sanitization Reference for all available sanitizers.

Validate

Add validation rules:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[validate(MaxStrlenValidator(100))]
    pub name: Text,

    #[validate(EmailValidator)]
    pub email: Text,

    #[validate(UrlValidator)]
    pub website: Nullable<Text>,
}
}

Validation happens after sanitization:

#![allow(unused)]
fn main() {
#[sanitizer(TrimSanitizer)]           // 1. First: trim whitespace
#[validate(MaxStrlenValidator(100))]  // 2. Then: check length
pub name: Text,
}

See Validation Reference for all available validators.

Candid

The #[candid] attribute adds candid::CandidType, serde::Serialize, and serde::Deserialize derives to the types generated by the Table macro. It requires the candid feature and is meant for Internet Computer integration. See the ic-dbms Schema Reference for details.

Alignment

Advanced: Configure memory alignment for dynamic-size tables:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "large_records"]
#[alignment = 64] // 64-byte alignment
pub struct LargeRecord {
    #[primary_key]
    pub id: Uint32,
    pub data: Text, // Variable-size field
}
}

When to use:

  • Performance tuning for specific access patterns
  • Optimizing memory layout for large records

Rules:

  • Minimum alignment is 8 bytes for dynamic types
  • Default alignment is 32 bytes
  • Fixed-size tables ignore this attribute (alignment equals record size)

Caution: Only change alignment if you understand the performance implications.


Migration Attributes

These attributes feed the schema migration subsystem. They produce no runtime behaviour for normal CRUD; the planner only consults them when the compiled schema diverges from the snapshot stored in memory.

See the Schema Migrations Guide for the end-to-end flow (drift detection, pending_migrations, migrate(policy)).

Default Value

Attach a per-column default that the migration planner uses when adding a non-nullable column to an existing table:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    pub name: Text,

    #[default = 0]
    pub login_count: Uint32,
}
}

How it is used:

  • When migrate() plans an AddColumn op for a non-nullable column, it pulls the value from #[default = ...] (after first checking Migrate::default_value).
  • Without a resolvable default, planning aborts with MigrationError::MissingDefault.

Rules:

  • The expression must convert into the column’s Value variant via From/Into. Examples: #[default = 0] on Uint32, #[default = ""] on Text, #[default = false] on Boolean.
  • The expression is evaluated at migration time, not at insert time, so it has no effect on regular INSERT calls — those still need an explicit value (or omit the field if nullable).
  • Custom data types must implement From<MyType> for Value; the #[derive(CustomDataType)] macro emits this automatically.
  • Defaults are persisted into the table’s snapshot (ColumnSnapshot::default), so the planner can compare them across releases.

Combining with nullable:

#![allow(unused)]
fn main() {
// Redundant — nullable columns default to NULL implicitly. Don't write
// #[default] on a Nullable<T> field.
pub bio: Nullable<Text>,
}

Renamed From

Tell the migration planner that a column used to be known by one or more previous names:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[renamed_from("username", "user_name")]
    pub name: Text,
}
}

How it is used:

When planning a migration, the planner first matches stored columns against compiled columns by name. For each compiled column with no direct match, it walks renamed_from in order and looks for a stored column with one of those names. The first hit is emitted as a RenameColumn op, preserving the column’s data.

Rules:

  • Entries are string literals.
  • Order matters: list newer renames first, older renames last (mirroring the chronological order of releases).
  • A stored column matched by renamed_from is not matched by another compiled column. If two compiled columns claim the same previous name, the earlier-declared field wins.
  • Without #[renamed_from], a column rename is indistinguishable from a DropColumn + AddColumn pair, which loses data.

Migrate Override

By default, #[derive(Table)] emits an empty impl Migrate for T {} for every table, giving you trait defaults for default_value and transform_column. Add #[migrate] at the struct level to suppress that emission and provide a hand-written impl:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "events"]
#[migrate]
pub struct Event {
    #[primary_key]
    pub id: Uint32,
    pub kind: Text,
    pub severity: Uint8,
}

impl Migrate for Event {
    fn default_value(column: &str) -> Option<Value> {
        match column {
            "severity" => Some(Value::Uint8(Uint8(1))),
            _ => None,
        }
    }

    fn transform_column(column: &str, old: Value) -> DbmsResult<Option<Value>> {
        match column {
            // Example: convert legacy text severities into the new Uint8 column.
            "severity" => match old {
                Value::Text(Text(s)) => match s.as_str() {
                    "low" => Ok(Some(Value::Uint8(Uint8(1)))),
                    "medium" => Ok(Some(Value::Uint8(Uint8(5)))),
                    "high" => Ok(Some(Value::Uint8(Uint8(9)))),
                    other => Err(DbmsError::Migration(MigrationError::TransformAborted {
                        table: "events".into(),
                        column: column.into(),
                        reason: format!("unknown severity `{other}`"),
                    })),
                },
                _ => Ok(None),
            },
            _ => Ok(None),
        }
    }
}
}

When to use #[migrate]:

  • The new column is non-nullable and the default cannot be a constant literal (e.g. requires hashing the row, or pulls from another column).
  • A column changed to an incompatible type that is not in the widening whitelist, and you can derive the new value from the old one.

Trait contract:

MethodReturnsEffect
default_value(column)Some(v)Use v for AddColumn on column.
default_value(column)NoneFall back to #[default = ...], else MigrationError::MissingDefault.
transform_column(column, old)Ok(Some(v))Replace stored value with v.
transform_column(column, old)Ok(None)No transform; framework errors with MigrationError::IncompatibleType unless the type change is a whitelisted widening.
transform_column(column, old)Err(_)Abort the migration; the journaled session rolls back.

Note: Without #[migrate], do not write impl Migrate for T {} yourself — the macro already emitted one and you would get a duplicate-impl error.


Generated Types

The Table macro generates several types for each table. Generated types use the same visibility as the table struct, so a private table struct can derive Table and its generated types stay private too.

Record Type

{StructName}Record - The full record type returned from queries:

#![allow(unused)]
fn main() {
// Generated from User struct
pub struct UserRecord {
    pub id: Uint32,
    pub name: Text,
    pub email: Text,
}

// Usage
let users: Vec<UserRecord> = database.select::<User>(query)?;

for user in users {
    println!("{}: {}", user.id, user.name);
}
}

InsertRequest Type

{StructName}InsertRequest - Request type for inserting records:

#![allow(unused)]
fn main() {
// Generated from User struct
pub struct UserInsertRequest {
    pub id: Uint32,
    pub name: Text,
    pub email: Text,
}

// Usage
let user = UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    email: "alice@example.com".into(),
};

database.insert::<User>(user)?;
}

UpdateRequest Type

{StructName}UpdateRequest - Request type for updating records:

#![allow(unused)]
fn main() {
// Generated from User struct (with builder pattern)
let update = UserUpdateRequest::builder()
    .set_name("New Name".into())
    .set_email("new@example.com".into())
    .filter(Filter::eq("id", Value::Uint32(1.into())))
    .build();

// Usage
database.update::<User>(update)?;
}

The generated update request stores its filter in a where_clause field, so where_clause is a reserved column name: a table field with that name is rejected with a compile error.

Builder methods:

  • set_{field_name}(value) - Set a field value
  • filter(Filter) - WHERE clause (required)
  • build() - Build the update request

ForeignFetcher Type

{StructName}ForeignFetcher - Internal type for eager loading:

#![allow(unused)]
fn main() {
// Generated automatically, used internally
// You typically don't interact with this directly
}

Complete Example

#![allow(unused)]
fn main() {
// schema/src/lib.rs
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[sanitizer(TrimSanitizer)]
    #[validate(MaxStrlenValidator(100))]
    pub name: Text,

    #[unique]
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(LowerCaseSanitizer)]
    #[validate(EmailValidator)]
    pub email: Text,

    pub created_at: DateTime,

    pub is_active: Boolean,
}

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
    #[primary_key]
    pub id: Uuid,

    #[validate(MaxStrlenValidator(200))]
    pub title: Text,

    pub content: Text,

    pub published: Boolean,

    #[index(group = "author_date")]
    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,

    pub metadata: Nullable<Json>,

    #[index(group = "author_date")]
    pub created_at: DateTime,
}

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "comments"]
pub struct Comment {
    #[primary_key]
    pub id: Uuid,

    #[validate(MaxStrlenValidator(1000))]
    pub content: Text,

    #[foreign_key(entity = "User", table = "users", column = "id")]
    pub author_id: Uint32,

    #[foreign_key(entity = "Post", table = "posts", column = "id")]
    pub post_id: Uuid,

    pub created_at: DateTime,
}
}

For the Internet Computer, see the ic-dbms Schema Reference.


Best Practices

1. Keep schema in a separate crate

my-project/
├── schema/           # Reusable types
│   ├── Cargo.toml
│   └── src/lib.rs
└── app/              # Application using the database
    ├── Cargo.toml
    └── src/lib.rs

2. Use appropriate primary key types

#![allow(unused)]
fn main() {
// Sequential IDs - simple, good for internal use
pub id: Uint32,

// UUIDs - better for distributed systems, no guessing
pub id: Uuid,
}

3. Always validate user input

#![allow(unused)]
fn main() {
#[validate(MaxStrlenValidator(1000))]  // Prevent huge strings
pub content: Text,

#[validate(EmailValidator)]  // Validate format
pub email: Text,
}

4. Use nullable for optional fields

#![allow(unused)]
fn main() {
pub phone: Nullable<Text>,  // Clearly optional
pub bio: Nullable<Text>,
}

5. Consider sanitization for consistency

#![allow(unused)]
fn main() {
#[sanitizer(TrimSanitizer)]
#[sanitizer(LowerCaseSanitizer)]
pub email: Text,  // Always lowercase, no whitespace
}

6. Document your schema

#![allow(unused)]
fn main() {
/// User account information
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    /// Unique user identifier
    #[primary_key]
    pub id: Uint32,

    /// User's display name (max 100 chars)
    #[validate(MaxStrlenValidator(100))]
    pub name: Text,
}
}

Data Types


Overview

wasm-dbms provides a rich set of data types for defining table schemas. Each type maps to standard Rust types for seamless integration.

Type categories:

CategoryTypes
IntegersUint8, Uint16, Uint32, Uint64, Int8, Int16, Int32, Int64
DecimalDecimal
TextText
BooleanBoolean
Date/TimeDate, DateTime
BinaryBlob
IdentifiersUuid
Semi-structuredJson
WrapperNullable<T>

Note: For Internet Computer types such as Principal, see the ic-dbms documentation.


Integer Types

Unsigned Integers

Uint8 - 8-bit unsigned integer (0 to 255)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Uint8;

#[derive(Table, ...)]
#[table = "settings"]
pub struct Setting {
    #[primary_key]
    pub id: Uint32,
    pub priority: Uint8,  // 0-255
}

// Usage
let setting = SettingInsertRequest {
    id: 1.into(),
    priority: 10.into(),  // or Uint8::from(10)
};
}

Uint16 - 16-bit unsigned integer (0 to 65,535)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Uint16;

pub struct Product {
    pub stock_count: Uint16,  // 0-65,535
}

let count: Uint16 = 1000.into();
}

Uint32 - 32-bit unsigned integer (0 to 4,294,967,295)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Uint32;

pub struct User {
    #[primary_key]
    pub id: Uint32,  // Common for primary keys
}

let id: Uint32 = 12345.into();
}

Uint64 - 64-bit unsigned integer (0 to 18,446,744,073,709,551,615)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Uint64;

pub struct Transaction {
    pub amount_e8s: Uint64,  // For large numbers like token amounts
}

let amount: Uint64 = 1_000_000_000u64.into();
}

Signed Integers

Int8 - 8-bit signed integer (-128 to 127)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Int8;

pub struct Temperature {
    pub celsius: Int8,  // -128 to 127
}

let temp: Int8 = (-10).into();
}

Int16 - 16-bit signed integer (-32,768 to 32,767)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Int16;

pub struct Altitude {
    pub meters: Int16,  // Can be negative (below sea level)
}

let altitude: Int16 = (-100).into();
}

Int32 - 32-bit signed integer (-2,147,483,648 to 2,147,483,647)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Int32;

pub struct Account {
    pub balance_cents: Int32,  // Can be negative (debt)
}

let balance: Int32 = (-5000).into();
}

Int64 - 64-bit signed integer

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Int64;

pub struct Statistics {
    pub total_change: Int64,  // Large signed values
}

let change: Int64 = (-1_000_000_000i64).into();
}

Decimal

Decimal - Arbitrary-precision decimal number

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Decimal;

pub struct Product {
    pub price: Decimal,      // $19.99
    pub weight_kg: Decimal,  // 2.5
}

// From f64
let price: Decimal = 19.99.into();

// From string (more precise)
let price: Decimal = "19.99".parse().unwrap();

// With sanitizer for rounding
#[derive(Table, ...)]
#[table = "products"]
pub struct Product {
    #[primary_key]
    pub id: Uint32,
    #[sanitizer(RoundToScaleSanitizer(2))]  // Round to 2 decimal places
    pub price: Decimal,
}
}

Note: Use RoundToScaleSanitizer to ensure consistent decimal precision.


Text

Text - UTF-8 string

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Text;

pub struct User {
    pub name: Text,
    pub email: Text,
    pub bio: Text,
}

// From &str
let name: Text = "Alice".into();

// From String
let email: Text = String::from("alice@example.com").into();

// Access the string
let text: Text = "Hello".into();
assert_eq!(text.as_str(), "Hello");
}

With validation:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    #[validate(MaxStrlenValidator(100))]
    pub name: Text,
    #[validate(EmailValidator)]
    pub email: Text,
}
}

With sanitization:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(CollapseWhitespaceSanitizer)]
    pub name: Text,
    #[sanitizer(LowerCaseSanitizer)]
    pub email: Text,
}
}

Boolean

Boolean - True or false value

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Boolean;

pub struct User {
    pub is_active: Boolean,
    pub email_verified: Boolean,
}

let active: Boolean = true.into();
let verified: Boolean = false.into();

// Convert back
let value: bool = active.into();
}

Filtering by boolean:

#![allow(unused)]
fn main() {
// Find active users
let filter = Filter::eq("is_active", Value::Boolean(true));

// Find unverified users
let filter = Filter::eq("email_verified", Value::Boolean(false));
}

Date and Time

Date

Date - Calendar date (year, month, day)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Date;

pub struct Event {
    pub event_date: Date,
}

// Create from components
let date = Date::new(2024, 6, 15);  // June 15, 2024

// From chrono NaiveDate (if using chrono)
use chrono::NaiveDate;
let naive = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let date: Date = naive.into();
}

DateTime

DateTime - Date and time with timezone

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::DateTime;

pub struct User {
    pub created_at: DateTime,
    pub last_login: DateTime,
}

// Current time
let now = DateTime::now();

// From chrono DateTime<Utc>
use chrono::{DateTime as ChronoDateTime, Utc};
let chrono_dt: ChronoDateTime<Utc> = Utc::now();
let dt: DateTime = chrono_dt.into();
}

With sanitization:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "events"]
pub struct Event {
    #[primary_key]
    pub id: Uint32,
    #[sanitizer(UtcSanitizer)] // Convert to UTC
    pub scheduled_at: DateTime,
}
}

Binary Data

Blob

Blob - Binary large object (byte array)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Blob;

pub struct Document {
    pub content: Blob,      // File content
    pub thumbnail: Blob,    // Image data
}

// From Vec<u8>
let data: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47];  // PNG header
let blob: Blob = data.into();

// From slice
let blob: Blob = Blob::from(&[1, 2, 3, 4][..]);

// Access bytes
let bytes: &[u8] = blob.as_slice();
}

Note: Be mindful of storage costs when storing large blobs. Consider storing only references (hashes, URLs) for very large files.


Identifiers

Uuid

Uuid - Universally unique identifier (128-bit)

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Uuid;

pub struct Order {
    #[primary_key]
    pub id: Uuid,  // UUID as primary key
}

// Generate new UUID
let id = Uuid::new_v4();

// From string
let id: Uuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();

// From bytes
let bytes: [u8; 16] = [/* 16 bytes */];
let id = Uuid::from_bytes(bytes);
}

Benefits over sequential IDs:

  • Globally unique without coordination
  • No sequential guessing
  • Safe for distributed systems

Semi-Structured Data

Json

Json - JSON object or array

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::Json;
use std::str::FromStr;

pub struct User {
    pub metadata: Json,    // Flexible schema
    pub preferences: Json, // User settings
}

// From string
let json = Json::from_str(r#"{"theme": "dark", "language": "en"}"#).unwrap();

// From serde_json::Value
use serde_json::json;
let json: Json = json!({
    "notifications": true,
    "timezone": "UTC"
}).into();
}

Querying JSON:

#![allow(unused)]
fn main() {
// Check if JSON contains pattern
let filter = Filter::json("metadata", JsonFilter::contains(
    Json::from_str(r#"{"active": true}"#).unwrap()
));

// Extract and compare
let filter = Filter::json("preferences",
    JsonFilter::extract_eq("theme", Value::Text("dark".into()))
);

// Check path exists
let filter = Filter::json("metadata", JsonFilter::has_key("email"));
}

See the JSON Reference for comprehensive JSON documentation.


Nullable

Nullable<T> - Optional value wrapper

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{Nullable, Text, Uint32};

pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    pub phone: Nullable<Text>,      // Optional phone number
    pub age: Nullable<Uint32>,      // Optional age
    pub bio: Nullable<Text>,        // Optional biography
}

// Insert with value
let user = UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    phone: Nullable::Value("555-1234".into()),
    age: Nullable::Null,
    bio: Nullable::Null,
};

// Check if null
let phone = user.phone;
match phone {
    Nullable::Value(p) => println!("Phone: {}", p.as_str()),
    Nullable::Null => println!("No phone number"),
}
}

Filtering nullable fields:

#![allow(unused)]
fn main() {
// Find users with phone numbers
let filter = Filter::not_null("phone");

// Find users without phone numbers
let filter = Filter::is_null("phone");

// Find users with specific phone
let filter = Filter::eq("phone", Value::Text("555-1234".into()));
}

Nullable foreign keys:

#![allow(unused)]
fn main() {
pub struct Employee {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    #[foreign_key(entity = "Employee", table = "employees", column = "id")]
    pub manager_id: Nullable<Uint32>, // Top-level employees have no manager
}
}

Custom Types

Beyond the built-in types listed above, wasm-dbms supports user-defined custom data types. Custom types let you store enums, structs, and newtypes in your tables by implementing the CustomDataType trait.

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "tasks"]
pub struct Task {
    #[primary_key]
    pub id: Uint32,
    #[custom_type]
    pub priority: Priority, // User-defined custom type
}
}

See the Custom Data Types Guide for step-by-step instructions on defining and using custom types.


Type Conversion Reference

wasm-dbms TypeRust Type
Uint8u8
Uint16u16
Uint32u32
Uint64u64
Int8i8
Int16i16
Int32i32
Int64i64
Decimalrust_decimal::Decimal
TextString
Booleanbool
Datechrono::NaiveDate
DateTimechrono::DateTime<Utc>
BlobVec<u8>
Uuiduuid::Uuid
Jsonserde_json::Value
Nullable<T>Option<T>

Note: For the Candid mapping of these types, see the ic-dbms documentation.

Conversion examples:

#![allow(unused)]
fn main() {
// Rust primitive to wasm-dbms type
let uint: Uint32 = 42u32.into();
let text: Text = "hello".into();
let boolean: Boolean = true.into();

// wasm-dbms type to Rust primitive
let num: u32 = uint.into();
let s: String = text.into();
let b: bool = boolean.into();
}

Query API Reference


Overview

A Query describes what to retrieve from the database: which rows match, which columns to return, how to order and paginate them, and how to combine data across tables. Queries are constructed with QueryBuilder and consumed by Database::select, Database::select_raw, and Database::select_join.

select_join returns a JoinResultSet with the selected columns once and the rows as Vec<Value>. See Join Results.

For an introductory walkthrough, see the Querying Guide.


Query Struct

#![allow(unused)]
fn main() {
pub struct Query {
    columns: Select,
    pub distinct_by: Vec<String>,
    pub eager_relations: Vec<String>,
    pub filter: Option<Filter>,
    pub group_by: Vec<String>,
    pub having: Option<Filter>,
    pub joins: Vec<Join>,
    pub limit: Option<usize>,
    pub offset: Option<usize>,
    pub order_by: Vec<(String, OrderDirection)>,
}
}
FieldTypeDescription
columnsSelectSelect::All or Select::Columns(Vec<String>)
distinct_byVec<String>Columns used to deduplicate results
eager_relationsVec<String>Foreign-key relations to load eagerly
filterOption<Filter>WHERE-clause expression
group_byVec<String>GROUP BY columns for aggregate queries
havingOption<Filter>HAVING filter applied to aggregated groups
joinsVec<Join>Join clauses (only valid via select_join)
limitOption<usize>Maximum number of records to return
offsetOption<usize>Number of records to skip
order_byVec<(String, OrderDirection)>Multi-column ordering

Use Query::builder() to obtain a QueryBuilder.


QueryBuilder

Field Selection

MethodEffect
.all()Selects all columns (Select::All)
.field(name)Adds a single column to the selection
.fields(iter)Adds multiple columns

The primary key is always included by Database::select::<T> even when not explicitly listed.

Filters

MethodEffect
.filter(Option<Filter>)Replaces the current filter
.and_where(Filter)Combines with existing filter using AND
.or_where(Filter)Combines with existing filter using OR

See Filters in the Querying Guide and JSON Filters for the full filter API.

Joins

MethodJoin type
.inner_join(table, left_col, right_col)INNER
.left_join(table, left_col, right_col)LEFT
.right_join(table, left_col, right_col)RIGHT
.full_join(table, left_col, right_col)FULL

Queries containing joins must be executed via Database::select_join. Calling Database::select::<T> with a joined query returns QueryError::JoinInsideTypedSelect.

A join condition matches only equal, non-null values. A NULL join key never matches another row, including a row whose join key is also NULL. Outer joins retain such rows as unmatched and fill the missing side with Value::Null.

Join Results

Database::select_join returns a JoinResultSet:

FieldTypeContent
columnsVec<JoinColumnDef>One description per selected column, in row order, with table set
rowsVec<Vec<Value>>One entry per row; rows[r][c] belongs to columns[c]

Helpers: len(), is_empty(), column_index("table.column") resolves a name to a column position, row(index) and iter() give JoinRow views, and &result can be iterated directly. A JoinRow offers get("table.column"), iter() over (column, value) pairs, columns(), and values(). A bare name matches the first column with that name in row order. columns is complete even when rows is empty. With the candid feature the type derives CandidType.

Eager Loading

#![allow(unused)]
fn main() {
.with("posts")
}

Adds a foreign-key relation to load eagerly. Each relation is loaded once via a batch fetch keyed by the foreign-key column.

Distinct

#![allow(unused)]
fn main() {
.distinct(&["name"])
.distinct(&["category", "vendor"])
}

Sets distinct_by to the supplied list of column names. Rows are deduplicated by the tuple of values across those columns; the first row encountered for each distinct tuple is retained. Passing an empty slice is a no-op.

Semantics:

  • Columns are looked up on the source record (ValuesSource::This).
  • Missing columns are treated as Value::Null, so listing an unknown column collapses every row into a single result.
  • Deduplication runs before ordering, offset, and limit.
  • The selected fields (Select::Columns) do not need to include the distinct_by columns.

Aggregations

#![allow(unused)]
fn main() {
.group_by(&["category"])
.having(Filter::gt("count", Value::Uint64(10u64.into())))
}
MethodEffect
.group_by(&[col...])Sets group_by to the supplied list of columns
.having(Filter)Sets the HAVING filter applied to aggregated groups

Aggregations operate over the rows that survive WHERE and DISTINCT. Each group of rows sharing the same group_by tuple produces one AggregatedRow. The aggregate functions to compute are described by AggregateFunction; their results are returned as AggregatedValue entries inside the row.

The HAVING filter is evaluated after aggregation, against the grouping keys and aggregate results.

Ordering

MethodEffect
.order_by_asc(column)Appends ascending sort by column
.order_by_desc(column)Appends descending sort by column

Multiple order_by_* calls produce stable multi-key sorts; later keys break ties from earlier keys.

Pagination

MethodEffect
.limit(usize)Caps the number of records returned
.offset(usize)Skips the first N records

Aggregate Types

Types used to describe and return aggregated query results. All three are re-exported from the wasm-dbms-api prelude.

AggregateFunction

#![allow(unused)]
fn main() {
pub enum AggregateFunction {
    Count(Option<String>),
    Sum(String),
    Avg(String),
    Min(String),
    Max(String),
}
}

Describes one aggregate function to compute over a group of rows.

VariantSQL equivalentNotes
Count(None)COUNT(*)Counts every row in the group
Count(Some(c))COUNT(c)Counts non-null values of column c
Sum(c)SUM(c)Sum of c across the group
Avg(c)AVG(c)Arithmetic mean of c
Min(c)MIN(c)Minimum value of c
Max(c)MAX(c)Maximum value of c

AggregatedRow

#![allow(unused)]
fn main() {
pub struct AggregatedRow {
    pub group_keys: Vec<Value>,
    pub values: Vec<AggregatedValue>,
}
}

A single row of aggregated output. group_keys holds the values of the group_by columns that identify the group; values holds the aggregate results in the same order as the AggregateFunction list supplied with the query.

AggregatedValue

#![allow(unused)]
fn main() {
pub enum AggregatedValue {
    Count(u64),
    Sum(Value),
    Avg(Value),
    Min(Value),
    Max(Value),
}
}

Carries the result of one aggregate function. Count is always a u64; the remaining variants wrap a Value whose concrete variant depends on the source column’s data type.


Execution Order

The select pipeline applies the query elements in this order — matching standard SQL semantics:

  1. WHERE — filter is applied while scanning records (or via an index plan).
  2. DISTINCT — distinct_by deduplicates the surviving rows.
  3. GROUP BY / aggregates — when group_by is set, surviving rows are bucketed by the grouping tuple and the requested AggregateFunctions are computed per bucket, producing AggregatedRows.
  4. HAVING — having filters the aggregated groups.
  5. Eager loading — relations declared by with(...) are batch-fetched (non-aggregate selects only).
  6. Column selection — non-selected columns are dropped from each row.
  7. ORDER BY — order_by keys are applied in declared order.
  8. OFFSET / LIMIT — applied last when order_by or distinct_by is set; otherwise applied during the scan for early termination.

When neither order_by nor distinct_by is present, the engine applies offset/limit during iteration to avoid materialising the entire result set.


Errors

All variants come from QueryError. Most are surfaced at planning time (before any rows are scanned) so callers fail fast.

Aggregate-specific (Database::aggregate)

ConditionVariant
SUM or AVG references a non-numeric columnInvalidQuery("aggregate requires numeric column: '<col>'")
Aggregate references a column not on the tableUnknownColumn(<col>)
GROUP BY references a column not on the tableUnknownColumn(<col>)
HAVING references unknown column or agg{N}InvalidQuery("HAVING references unknown column or aggregate: '<col>'")
ORDER BY references unknown agg{N}InvalidQuery("ORDER BY references unknown aggregate output: '<col>'")
LIKE used inside a HAVING clauseInvalidQuery("LIKE is not supported in HAVING")
JSON filter used inside a HAVING clauseInvalidQuery("JSON filters are not supported in HAVING")
Query carries joins on an aggregate callInvalidQuery("joins are not supported in aggregate queries")
Query carries eager_relations on an aggregateInvalidQuery("eager relations are not supported in aggregate queries")

Non-aggregate select paths

ConditionVariant
group_by or having set on select / select_raw / select_joinAggregateClauseInSelect (use Database::aggregate)
Query carries joins on a typed select::<T> callJoinInsideTypedSelect

SQL Reference


Overview

The wasm-dbms-sql crate runs SQL statements against a wasm-dbms database. It covers reading and writing data:

StatementPurpose
SELECTRead rows, join tables, aggregate
INSERTAdd one row
UPDATEChange the rows that match a condition
DELETERemove the rows that match a condition
BEGINOpen a transaction
COMMITApply the transaction
ROLLBACKDiscard the transaction

Tables, columns, indexes, and foreign keys are defined in Rust with #[derive(Table)]. There is no SQL to create or alter them: schema changes go through migrations.

SQL is executed by SqlEngine::execute, one statement per call. For setup and worked examples, see the SQL Guide.


Lexical Structure

Whitespace and Comments

Spaces, tabs, and line breaks separate tokens and are otherwise ignored. A statement can span any number of lines.

Two comment forms are skipped like whitespace:

-- a line comment runs to the end of the line
SELECT name /* a block comment can sit between tokens
               and span lines */ FROM users;

Block comments do not nest: the first */ ends the comment. A block comment that is never closed is an error. # does not start a comment.

Keywords

Keywords are case-insensitive: SELECT, select, and SeLeCt are the same. Every keyword is reserved, which means it cannot be used as a table, column, or alias name unless it is quoted. The full list is in Reserved Words.

The aggregate function names COUNT, SUM, AVG, MIN, and MAX are not reserved. They are case-insensitive and are read as functions only when an opening parenthesis follows, so a column can be called count or max.

Identifiers

Identifiers name tables, columns, and aliases. They are case-sensitive and must match the names declared in the Rust schema exactly: users and Users are different tables.

An unquoted identifier starts with an ASCII letter or _, followed by ASCII letters, digits, or _.

A quoted identifier is written between double quotes. It can contain any character, including spaces and non-ASCII letters. Write "" for a double quote inside it. Quoting is required for a name that is a reserved word, and never changes the meaning of a name that does not need it.

SELECT "order", "first name" FROM "group" WHERE "select" = 1;

Backticks (`name`) and square brackets ([name]) are not accepted. Double quotes always mean an identifier, never a string.

A column can be qualified with its table: users.name. When the table has an alias, the alias is the qualifier.

Literals

KindFormExamples
IntegerDigits0, 42, -7
DecimalDigits, a dot, digits1.5, 0.25, -10.00
StringText between single quotes'Alice', 'it''s', ''
BooleanTRUE or FALSETRUE, false
NullNULLNULL
  • A number is made negative with a leading -. Unsigned integer literals go up to 18446744073709551615.
  • A decimal needs digits on both sides of the dot. .5, 5., and exponent notation such as 1e3 are not accepted.
  • A string can contain line breaks. Write '' for a single quote inside it. A backslash has no special meaning in a string.
  • There are no date, UUID, or binary literals. Those values are written as strings and converted by the type of the column they are used with. See Type Conversion.

Placeholders

A ? stands for a value supplied separately when the statement is executed. Placeholders are positional: the first ? takes the first parameter, the second ? the second, and so on.

SELECT name FROM users WHERE age > ? AND city = ? LIMIT ?;

A placeholder can be used wherever a literal can, and as the argument of LIMIT and OFFSET. Named (:name, @name) and numbered ($1) placeholders are not accepted. See Parameters.

Statement Terminator

A statement can end with one ;. It is optional. Only one statement is accepted per call: anything after the ; other than whitespace and comments is an error.


Grammar

The grammar below uses these conventions: [ x ] is optional, { x } repeats zero or more times, a | b is a choice, and quoted words are keywords or symbols. Keywords are shown in upper case and are case-insensitive.

statement       = ( select | insert | update | delete
                  | begin | commit | rollback ) [ ";" ]

begin           = "BEGIN" [ "TRANSACTION" ]
commit          = "COMMIT" [ "TRANSACTION" ]
rollback        = "ROLLBACK" [ "TRANSACTION" ]

select          = "SELECT" [ "DISTINCT" ] select_list
                  "FROM" table_ref { join }
                  [ "WHERE" condition ]
                  [ "GROUP" "BY" column_ref { "," column_ref } ]
                  [ "HAVING" condition ]
                  [ "ORDER" "BY" order_item { "," order_item } ]
                  [ limit_offset ]
select_list     = "*" | select_item { "," select_item }
select_item     = operand [ "AS" identifier ]
table_ref       = identifier [ [ "AS" ] identifier ]
join            = join_type table_ref "ON" column_ref "=" column_ref
join_type       = [ "INNER" ] "JOIN"
                | ( "LEFT" | "RIGHT" | "FULL" ) [ "OUTER" ] "JOIN"
order_item      = operand [ "ASC" | "DESC" ]
limit_offset    = "LIMIT" row_count [ "OFFSET" row_count ]
                | "OFFSET" row_count [ "LIMIT" row_count ]
row_count       = integer | "?"

insert          = "INSERT" "INTO" identifier
                  "(" identifier { "," identifier } ")"
                  "VALUES" "(" value { "," value } ")"

update          = "UPDATE" identifier
                  "SET" assignment { "," assignment }
                  "WHERE" condition
assignment      = identifier "=" value

delete          = "DELETE" "FROM" identifier
                  "WHERE" condition
                  [ "CASCADE" | "RESTRICT" ]

condition       = and_condition { "OR" and_condition }
and_condition   = not_condition { "AND" not_condition }
not_condition   = "NOT" not_condition
                | "(" condition ")"
                | predicate
predicate       = operand comparison value
                | operand [ "NOT" ] "IN" "(" value { "," value } ")"
                | operand [ "NOT" ] "LIKE" ( string | "?" )
                | operand "IS" [ "NOT" ] "NULL"
comparison      = "=" | "!=" | "<>" | "<" | "<=" | ">" | ">="

operand         = column_ref | aggregate
column_ref      = identifier [ "." identifier ]
aggregate       = "COUNT" "(" ( "*" | column_ref ) ")"
                | ( "SUM" | "AVG" | "MIN" | "MAX" ) "(" column_ref ")"

value           = literal | "?"
literal         = [ "-" ] integer | [ "-" ] decimal | string
                | "TRUE" | "FALSE" | "NULL"

Two rules are not visible in the grammar:

  • An aggregate is allowed in the select list, in HAVING, and in ORDER BY, but not in WHERE.
  • NULL is allowed as a value in INSERT and in UPDATE ... SET, but not on the right of a comparison or inside an IN list. Use IS NULL instead.

SELECT

SELECT name, age
FROM users
WHERE age >= 18
ORDER BY age DESC, name
LIMIT 10 OFFSET 20;

Select List

* returns every column, in the order the columns are declared in the table. For a join, it returns the columns of every table, in the order of the tables in the statement. * cannot be mixed with other items, and table.* is not supported.

A list of columns returns exactly those columns, in the order written. The same column can appear more than once.

AS gives a column a different name in the result. The AS keyword is required.

SELECT id AS user_id, name AS "full name" FROM users;

The select list can also contain aggregate functions. Expressions, arithmetic, literals, and scalar functions are not supported.

FROM and Table Aliases

FROM names the table to read. An alias can follow the table name, with or without AS:

SELECT u.name FROM users AS u WHERE u.id = 1;
SELECT u.name FROM users u WHERE u.id = 1;

Once a table has an alias, its columns are qualified with the alias and no longer with the table name: users.id is an error in the queries above.

JOIN

A join combines the rows of two tables whose columns are equal.

SELECT users.name, posts.title
FROM users
JOIN posts ON users.id = posts.user_id
LEFT JOIN comments ON comments.post_id = posts.id;
SyntaxRows returned
JOIN, INNER JOINOnly pairs of rows that match
LEFT JOIN, LEFT OUTER JOINAlso rows of the left side without a match
RIGHT JOIN, RIGHT OUTER JOINAlso rows of the joined table without a match
FULL JOIN, FULL OUTER JOINAlso unmatched rows of both sides

The columns of the missing side of an outer join are NULL. Null join keys never match, including two NULL values.

Rules for the ON condition:

  • It is exactly one equality between two columns.
  • Both columns must have the same underlying type; their nullability may differ. Incompatible types produce SqlError::TypeMismatch during planning.
  • One column belongs to the table being joined, and the other to a table that comes before it in the statement. The two sides can be written in either order.
  • AND, OR, other operators, and literals are not accepted. Put any other condition in WHERE.

In a join, a column name that exists in more than one table must be qualified, in every clause. An unqualified name that is ambiguous is an error.

Other rules:

  • A table can appear only once in a statement, so a table cannot be joined with itself.
  • CROSS JOIN, NATURAL JOIN, USING (...), and comma-separated tables in FROM are not supported.
  • DISTINCT, aggregate functions, GROUP BY, and HAVING cannot be used together with a join.

WHERE

WHERE keeps the rows for which the condition is true.

SELECT * FROM users
WHERE (age < 18 OR age > 65) AND email IS NOT NULL;

DISTINCT

SELECT DISTINCT returns each combination of the selected columns once.

SELECT DISTINCT city, country FROM users ORDER BY country, city;
  • With a column list, rows are compared on the listed columns. With *, rows are compared on every column.
  • Every ORDER BY column must be in the select list.
  • DISTINCT cannot be combined with a join or with aggregate functions.

GROUP BY and HAVING

GROUP BY puts the rows that have the same values in the listed columns into one group. The query then returns one row per group.

SELECT category, COUNT(*) AS items, SUM(price) AS revenue
FROM sales
WHERE price > 0
GROUP BY category
HAVING COUNT(*) >= 5
ORDER BY revenue DESC;
  • Every column in the select list must be listed in GROUP BY, or be used inside an aggregate function. SELECT * is not allowed.
  • Without GROUP BY, a query that uses an aggregate function treats the whole table as one group and returns one row, even when the table is empty.
  • HAVING filters the groups. Its condition can compare aggregate functions and grouped columns. An aggregate used in HAVING does not need to be in the select list. LIKE is not supported in HAVING.
  • WHERE is applied to the rows before they are grouped, and HAVING to the groups afterwards.

ORDER BY

ORDER BY sorts the result. Each item is a column, optionally followed by ASC (the default) or DESC. Earlier items take precedence; later items break ties.

SELECT name AS n, age FROM users ORDER BY age DESC, n;
  • An item can be an alias defined in the select list. When a name is both an alias and a column, the alias wins.
  • A column does not have to be in the select list, except with DISTINCT.
  • In a query with aggregate functions or GROUP BY, an item must be a grouped column, an aggregate function, or an alias of either.
  • Sorting by position (ORDER BY 1) and NULLS FIRST / NULLS LAST are not supported.

Without ORDER BY, the order of the rows is not defined.

LIMIT and OFFSET

LIMIT n returns at most n rows. OFFSET n skips the first n rows. Each takes a non-negative integer or a ? placeholder, and they can be written in either order. The maximum value is 4294967295 on every target; larger values are rejected so native and WASM builds accept the same statements.

SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 40;
SELECT * FROM users ORDER BY id LIMIT ? OFFSET ?;

Execution Order

A SELECT is evaluated in this order:

  1. FROM and JOIN build the rows.
  2. WHERE filters them.
  3. DISTINCT removes duplicates, or GROUP BY forms groups and the aggregate functions are computed.
  4. HAVING filters the groups.
  5. ORDER BY sorts.
  6. OFFSET and then LIMIT are applied.
  7. The select list picks and names the columns.

INSERT

INSERT INTO users (id, name, email) VALUES (1, 'Alice', NULL);
INSERT INTO users (id, name, email) VALUES (?, ?, ?);

INSERT adds one row and reports one affected row.

  • The column list is required, and each column can be listed once. There must be exactly one value per column.
  • A column that is not listed is left to the DBMS: a nullable column becomes NULL, and an #[autoincrement] column gets the next number. Leaving out any other column is an error.
  • Each value is a literal or a placeholder, converted to the type of its column. See Type Conversion.
  • Sanitizers, validators, primary key, unique, and foreign key checks run as they do for the typed API.

Inserting several rows in one statement, INSERT ... SELECT, and DEFAULT VALUES are not supported.


UPDATE

UPDATE users SET name = 'Bob', email = NULL WHERE id = 2;

UPDATE changes the listed columns of every row that matches the WHERE condition, and reports the number of rows written. Zero matching rows is not an error.

  • WHERE is required. A statement without it is rejected before anything is written. To change every row on purpose, write a condition that is true for all of them, such as WHERE id >= 0.
  • Each column can be assigned once. The new value is a literal or a placeholder: a value cannot be computed from a column (SET n = n + 1 is not supported).
  • Updating a primary key also updates the foreign keys that point at it.

Table aliases, ORDER BY, and LIMIT are not supported in UPDATE.


DELETE

DELETE FROM posts WHERE user_id = 3;
DELETE FROM users WHERE id = 3 CASCADE;

DELETE removes every row that matches the WHERE condition and reports the number of rows removed.

  • WHERE is required. A statement without it is rejected before anything is deleted.
  • An optional keyword after the condition says what happens when other rows reference a deleted row through a foreign key:
KeywordBehavior
RESTRICT (default)The statement fails and nothing is deleted.
CASCADEThe referencing rows are deleted too.

Outside a transaction, the reported count includes the rows removed by CASCADE. Inside a transaction, it only counts the rows that matched the condition.

CASCADE and RESTRICT are an extension to standard SQL, which has no way to choose the behavior in the statement.

Table aliases, ORDER BY, and LIMIT are not supported in DELETE.


Transactions

BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 1;
UPDATE accounts SET balance = 150 WHERE id = 2;
COMMIT;

Each statement above is a separate execute call. BEGIN returns the id of the new transaction; the statements that follow pass that id to execute, and so do COMMIT and ROLLBACK.

StatementEffect
BEGIN [TRANSACTION]Opens a transaction and returns its id
COMMIT [TRANSACTION]Applies every change of the transaction at once
ROLLBACK [TRANSACTION]Discards every change of the transaction
  • BEGIN is sent without a transaction id. Sending it with one is an error.
  • A statement sent with a transaction id runs inside that transaction and sees its uncommitted changes. Statements sent without an id, and other transactions, do not see them until COMMIT.
  • A statement that fails does not end the transaction. The program decides whether to continue, COMMIT, or ROLLBACK.
  • COMMIT checks the constraints again while applying the changes. If it fails, nothing is applied and the transaction is over.
  • COMMIT or ROLLBACK without a transaction id is an error, and COMMIT with the id of a transaction that is already over reports it as not found.
  • Without a transaction id, each statement is applied immediately and atomically.

START TRANSACTION, END, savepoints, and isolation level settings are not supported.


Conditions

A condition is used in WHERE and HAVING. It is built from predicates combined with AND, OR, NOT, and parentheses.

Every predicate has a column on its left side and literals or placeholders on its right side. In HAVING, the left side can also be an aggregate function. Comparing two columns with each other, a literal on the left side, arithmetic, BETWEEN, EXISTS, and subqueries are not supported.

Comparison Operators

OperatorMeaning
=Equal
!= or <>Not equal
<Less than
<=Less than or equal
>Greater than
>=Greater than or equal
SELECT * FROM users WHERE age >= 18 AND name != 'admin';

The value is converted to the type of the column before it is compared. Text is compared character by character, and case matters.

IN

IN is true when the column equals any value of the list. The list needs at least one value. NOT IN is its negation.

SELECT * FROM users WHERE id IN (1, 2, 3);
SELECT * FROM users WHERE city NOT IN ('Rome', ?);

LIKE

LIKE matches a text column against a pattern. NOT LIKE is its negation. The pattern is a string literal or a placeholder.

PatternMatches
%Any number of characters
_Exactly one character
\%A literal %
\_A literal _
\\A literal \
SELECT * FROM users WHERE email LIKE '%@example.com';
SELECT * FROM products WHERE code LIKE 'A_-%';

Matching is case-sensitive. A pattern that ends with a single backslash is an error.

IS NULL

IS NULL is true when the column has no value, and IS NOT NULL when it has one. They are the only way to test for NULL.

SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;

AND, OR, NOT

From highest to lowest precedence:

  1. Parentheses
  2. NOT
  3. AND
  4. OR

AND and OR group from left to right.

-- read as: a = 1 OR (b = 2 AND c = 3)
SELECT * FROM t WHERE a = 1 OR b = 2 AND c = 3;

-- read as: (NOT a = 1) AND b = 2
SELECT * FROM t WHERE NOT a = 1 AND b = 2;

SELECT * FROM t WHERE (a = 1 OR b = 2) AND NOT (c = 3 OR d = 4);

NULL Handling

A condition is either true or false for a row: there is no third “unknown” result as in standard SQL. For a column that is NULL:

  • =, IN, and LIKE are false.
  • !=, NOT IN, NOT LIKE, and NOT (...) around a false predicate are true. WHERE email != 'a@example.com' therefore also returns the rows where email is NULL.
  • The result of <, <=, >, and >= is not defined.

Add AND column IS NOT NULL when rows without a value must be left out. The position of NULL values in an ORDER BY is not defined either.


Aggregate Functions

FunctionResultResult typeOn no values
COUNT(*)Number of rowsUint640
COUNT(column)Number of rows where column is not nullUint640
SUM(column)Sum of the non-null valuesDecimalNULL
AVG(column)Mean of the non-null valuesDecimalNULL
MIN(column)Smallest non-null valueType of the columnNULL
MAX(column)Largest non-null valueType of the columnNULL
  • SUM and AVG need a numeric column: any integer type or Decimal.
  • The argument is a column. COUNT(DISTINCT column), expressions, and nested aggregates are not supported.
  • In the result, an aggregate column is named after the function call with the plain column name, in upper case: COUNT(*), SUM(price). Use AS to give it another name.
  • A literal compared with an aggregate in HAVING is converted to the result type of the aggregate.
SELECT COUNT(*), COUNT(email), AVG(age), MIN(age), MAX(age) FROM users;

Type Conversion

A value is always used with a column: it is assigned to it, or compared with it. The value is converted to the exact type of that column. A value that cannot be converted is an error, and the statement does nothing.

Literals by Column Type

Column typeIntegerDecimalStringTRUE / FALSE
Int8, Int16, Int32, Int64Yes, if in rangeNoNoNo
Uint8, Uint16, Uint32, Uint64Yes, if in rangeNoNoNo
DecimalYesYesNoNo
TextNoNoYesNo
BooleanNoNoNoYes
DateNoNoDate formatNo
DateTimeNoNoDate-time formatNo
UuidNoNoUUID formatNo
BlobNoNoHexadecimalNo
JsonNoNoJSON textNo
Custom typesNoNoNoNo
  • “No” is reported as a type mismatch. A value of the right kind that does not fit, such as 300 for a Uint8 column or '2026-02-30' for a Date column, is reported as an invalid value.
  • NULL can be written for any column type. Whether the column accepts it is checked by the DBMS.
  • Nullable<T> columns convert like T.
  • A custom data type has no literal form. Its values are passed as parameters.

String Formats

Column typeFormatExamples
DateYYYY-MM-DD'2026-04-24'
DateTimeYYYY-MM-DD, T or a space, HH:MM:SS, then optional parts'2026-04-24T10:30:00Z', '2026-04-24 10:30:00.5+02:00'
Uuid32 hexadecimal digits in groups of 8-4-4-4-12'550e8400-e29b-41d4-a716-446655440000'
BlobHexadecimal digits, two per byte, optional 0x prefix'deadbeef', '0x00FF', ''
JsonAny JSON document'{"tags": ["a", "b"]}', 'null'
  • A date must exist in the calendar: '2025-02-29' is rejected.
  • The optional parts of a date-time are a fraction of a second with one to six digits (.5, .123456), followed by a time zone: Z for UTC, or an offset such as +02:00 or -08:00. Without a time zone, the value is UTC.
  • Hexadecimal digits can be upper or lower case.
INSERT INTO events (day, at, token, payload, meta)
VALUES ('2026-04-24', '2026-04-24T10:30:00Z',
        '550e8400-e29b-41d4-a716-446655440000', 'deadbeef',
        '{"kind": "signup"}');

SELECT * FROM events WHERE day >= '2026-01-01';

Parameters

Parameters are the values passed to SqlEngine::execute next to the SQL text. There must be exactly one per ? placeholder.

A parameter is never read as SQL: its content cannot change the statement. Use parameters for every value that comes from outside the program.

A parameter is converted to the type of its column like a literal:

Parameter valueConverted as
Any integer typeAn integer literal: any integer column or Decimal, if in range
TextA string literal: Text, Date, DateTime, Uuid, Blob, Json
BooleanTRUE / FALSE
NullNULL
Decimal, Date, DateTime, Uuid, Blob, Json, customUsed as it is; the column must have the same type

For example, a Value::Uint64(5) parameter can be compared with a Uint8 column, and a Value::Text("2026-04-24") parameter with a Date column.

A Null parameter is accepted in INSERT and UPDATE ... SET, and rejected in a comparison or an IN list. A LIMIT or OFFSET parameter must be a non-negative integer. A LIKE parameter must be Text.


Results

SqlEngine::execute returns a SqlResult:

VariantReturned byContent
Rows(rows)SELECTThe rows, possibly none
RowsAffected(n)INSERT, UPDATE, DELETEThe number of rows written
TxBegin(id)BEGINThe id of the new transaction
TxCommitCOMMIT
TxRollbackROLLBACK

A row is a list of (JoinColumnDef, Value) pairs, one per column of the select list, in order. The column definition describes the column:

FieldContent
nameThe alias if one was given, otherwise the column or aggregate name
tableThe table the column comes from in a join; None otherwise
data_typeThe type of the value
nullableWhether the value can be NULL
primary_keyWhether the column is the primary key of its table
foreign_keyThe foreign key of the column, if it has one

table is always the real table name, even when the statement uses an alias.


Errors

A failed statement returns a SqlError. Syntax errors carry the line and column of the offending token, both starting at 1. The variants are described in the Errors Reference.


Limits

LimitValue
Statements per call1
Terms in the conditions of one statement256
Largest unsigned integer literal18446744073709551615

A term is one predicate, one AND, OR, or NOT, or one pair of parentheses. The conditions of WHERE and HAVING are counted together. The values of an IN list are not counted, so a long list of alternatives is best written with IN.


Reserved Words

These words cannot be used as names unless they are written between double quotes. Some are reserved for clauses that are not supported yet.

AND          AS           ASC          BEGIN        BY
CASCADE      COMMIT       CROSS        DELETE       DESC
DISTINCT     EXCEPT       FALSE        FROM         FULL
GROUP        HAVING       IN           INNER        INSERT
INTERSECT    INTO         IS           JOIN         LEFT
LIKE         LIMIT        NATURAL      NOT          NULL
OFFSET       ON           OR           ORDER        OUTER
RESTRICT     RIGHT        ROLLBACK     SELECT       SET
TRANSACTION  TRUE         UNION        UPDATE       USING
VALUES       WHERE

Not Supported

  • Schema statements: CREATE, ALTER, DROP, TRUNCATE. Use migrations.
  • Subqueries, UNION, INTERSECT, EXCEPT, and WITH.
  • Expressions and scalar functions: arithmetic, string concatenation, CASE, COALESCE, LOWER, and so on.
  • Comparisons between two columns, BETWEEN, and EXISTS.
  • Self-joins, CROSS JOIN, NATURAL JOIN, and USING.
  • DISTINCT or aggregate functions together with a join.
  • COUNT(DISTINCT ...).
  • Multi-row INSERT, INSERT ... SELECT, and RETURNING.
  • Several statements in one call.
  • Named and numbered placeholders.
  • Filters on the content of Json columns. Use the JSON filters of the query builder.
  • Eager loading of relations. Use a JOIN, or the query builder.

Validation Reference


Overview

Validators enforce constraints on data being inserted or updated. If validation fails, the operation is rejected with a Validation error.

Key points:

  • Validators run after sanitizers
  • Validation failure rejects the entire operation
  • Multiple validators can be applied to a single field
  • Validators are applied on both insert and update

Syntax

The #[validate(...)] attribute adds validation rules to fields:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    // Unit struct validator (no parameters)
    #[validate(EmailValidator)]
    pub email: Text,

    // Tuple struct validator (positional parameter)
    #[validate(MaxStrlenValidator(100))]
    pub name: Text,

    // Several validators, run top to bottom
    #[validate(MinStrlenValidator(3))]
    #[validate(MaxStrlenValidator(20))]
    pub username: Text,
}
}

When a field has several #[validate(...)] attributes, every validator runs in declaration order (top to bottom) and the value is rejected with the error of the first validator that fails. The generated table schema combines them into a ValidatorChain, which you can also build yourself when implementing TableSchema by hand.


Built-in Validators

All validators are available in wasm_dbms_api::prelude.

String Length Validators

String lengths are counted in Unicode characters (scalar values), not bytes: "🙂" and "é" each have length 1. A character written with a combining mark, such as e followed by U+0301, counts as two.

MaxStrlenValidator - Maximum string length

#![allow(unused)]
fn main() {
#[validate(MaxStrlenValidator(255))]
pub description: Text,  // Max 255 characters
}

MinStrlenValidator - Minimum string length

#![allow(unused)]
fn main() {
#[validate(MinStrlenValidator(8))]
pub password: Text,  // At least 8 characters
}

RangeStrlenValidator - String length within range

#![allow(unused)]
fn main() {
#[validate(RangeStrlenValidator(3, 50))]
pub username: Text,  // Between 3 and 50 characters
}

Format Validators

EmailValidator - Valid email format

#![allow(unused)]
fn main() {
#[validate(EmailValidator)]
pub email: Text,  // Must be valid email
}

UrlValidator - Valid URL format

#![allow(unused)]
fn main() {
#[validate(UrlValidator)]
pub website: Text,  // Must be valid URL
}

PhoneNumberValidator - Valid phone number format

#![allow(unused)]
fn main() {
#[validate(PhoneNumberValidator)]
pub phone: Text,  // Must be valid phone number
}

MimeTypeValidator - Valid MIME type format

#![allow(unused)]
fn main() {
#[validate(MimeTypeValidator)]
pub content_type: Text,  // e.g., "application/json", "image/png"
}

The value must be type/subtype, where both names follow the RFC 6838 section 4.2 restricted-name grammar: 1 to 127 characters, starting with an ASCII letter or digit, followed by ASCII letters, digits, or any of ! # $ & - ^ _ . +. Parameters such as ; charset=utf-8 are not accepted.

RgbColorValidator - Valid RGB color format

#![allow(unused)]
fn main() {
#[validate(RgbColorValidator)]
pub color: Text,  // e.g., "#FF5733", "rgb(255, 87, 51)"
}

Case Validators

CamelCaseValidator - Must be camelCase

#![allow(unused)]
fn main() {
#[validate(CamelCaseValidator)]
pub identifier: Text,  // e.g., "myVariableName"
}

KebabCaseValidator - Must be kebab-case

#![allow(unused)]
fn main() {
#[validate(KebabCaseValidator)]
pub slug: Text,  // e.g., "my-page-slug"
}

SnakeCaseValidator - Must be snake_case

#![allow(unused)]
fn main() {
#[validate(SnakeCaseValidator)]
pub code: Text,  // e.g., "my_constant_name"
}

Locale Validators

CountryIso639Validator - ISO 639 language code

#![allow(unused)]
fn main() {
#[validate(CountryIso639Validator)]
pub language: Text,  // e.g., "en", "es", "fr"
}

CountryIso3166Validator - ISO 3166 country code

#![allow(unused)]
fn main() {
#[validate(CountryIso3166Validator)]
pub country: Text,  // e.g., "US", "GB", "DE"
}

Implementing Custom Validators

Create a struct implementing the Validate trait:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{DbmsError, DbmsResult, Validate, Value};

/// Validates that a number is positive
pub struct PositiveValidator;

impl Validate for PositiveValidator {
    fn validate(&self, value: &Value) -> DbmsResult<()> {
        match value {
            Value::Int32(n) if n.0 > 0 => Ok(()),
            Value::Int64(n) if n.0 > 0 => Ok(()),
            Value::Decimal(d) if d.0 > rust_decimal::Decimal::ZERO => Ok(()),
            Value::Int32(_) | Value::Int64(_) | Value::Decimal(_) => {
                Err(DbmsError::Validation("Value must be positive".to_string()))
            }
            _ => Err(DbmsError::Validation(
                "PositiveValidator only applies to numeric types".to_string(),
            )),
        }
    }
}

// Usage
#[derive(Table, ...)]
#[table = "products"]
pub struct Product {
    #[primary_key]
    pub id: Uint32,
    #[validate(PositiveValidator)]
    pub price: Decimal,
}
}

Custom validator with parameters (tuple struct):

#![allow(unused)]
fn main() {
/// Validates that a string matches a regex pattern
pub struct RegexValidator(pub &'static str);

impl Validate for RegexValidator {
    fn validate(&self, value: &Value) -> DbmsResult<()> {
        if let Value::Text(text) = value {
            let re = regex::Regex::new(self.0).unwrap();
            if re.is_match(text.as_str()) {
                return Ok(());
            }
        }
        Err(DbmsError::Validation(
            format!("Value does not match pattern: {}", self.0)
        ))
    }
}

// Usage
#[validate(RegexValidator(r"^[A-Z]{2}-\d{4}$"))]
pub product_code: Text,  // Must match "XX-1234" format
}

Custom validator with named parameters:

#![allow(unused)]
fn main() {
/// Validates a number is within a range
pub struct RangeValidator {
    pub min: i64,
    pub max: i64,
}

impl Validate for RangeValidator {
    fn validate(&self, value: &Value) -> DbmsResult<()> {
        let num = match value {
            Value::Int32(n) => n.0 as i64,
            Value::Int64(n) => n.0,
            _ => return Err(DbmsError::Validation("RangeValidator requires integer".to_string())),
        };

        if num >= self.min && num <= self.max {
            Ok(())
        } else {
            Err(DbmsError::Validation(
                format!("Value must be between {} and {}", self.min, self.max)
            ))
        }
    }
}

// Usage
#[validate(RangeValidator, min = 1, max = 100)]
pub percentage: Int32,
}

Validation Errors

When validation fails, a DbmsError::Validation(String) is returned:

#![allow(unused)]
fn main() {
let result = database.insert::<User>(user);

match result {
    Ok(()) => println!("Insert successful"),
    Err(DbmsError::Validation(msg)) => {
        println!("Validation failed: {}", msg);
        // e.g., "Invalid email format"
        // e.g., "String length exceeds maximum of 100"
    }
    Err(e) => println!("Other error: {:?}", e),
}
}

Examples

Comprehensive user validation:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[validate(RangeStrlenValidator(2, 50))]
    pub name: Text,

    #[validate(EmailValidator)]
    pub email: Text,

    #[validate(MinStrlenValidator(8))]
    pub password_hash: Text,

    #[validate(PhoneNumberValidator)]
    pub phone: Nullable<Text>,

    #[validate(UrlValidator)]
    pub website: Nullable<Text>,

    #[validate(CountryIso3166Validator)]
    pub country: Nullable<Text>,

    #[validate(CountryIso639Validator)]
    pub language: Text,
}
}

Product validation:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "products"]
pub struct Product {
    #[primary_key]
    pub id: Uuid,

    #[validate(RangeStrlenValidator(1, 200))]
    pub name: Text,

    #[validate(MaxStrlenValidator(2000))]
    pub description: Text,

    #[validate(KebabCaseValidator)]
    pub slug: Text,

    #[validate(MimeTypeValidator)]
    pub image_type: Nullable<Text>,

    #[validate(RgbColorValidator)]
    pub accent_color: Nullable<Text>,
}
}

Combined with sanitizers:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "articles"]
pub struct Article {
    #[primary_key]
    pub id: Uuid,

    // Sanitize first, then validate
    #[sanitizer(TrimSanitizer)]
    #[validate(MaxStrlenValidator(200))]
    pub title: Text,

    // Convert to slug format, then validate
    #[sanitizer(SlugSanitizer)]
    #[validate(KebabCaseValidator)]
    pub slug: Text,

    #[sanitizer(TrimSanitizer)]
    pub content: Text,
}
}

Sanitization Reference


Overview

Sanitizers automatically transform data before it’s stored in the database. Unlike validators (which reject invalid data), sanitizers modify data to conform to expected formats.

Key points:

  • Sanitizers run before validators
  • Data is transformed, not rejected
  • Multiple sanitizers can be chained
  • Sanitizers apply on both insert and update

Syntax

The #[sanitizer(...)] attribute adds sanitization rules to fields:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    // Unit struct sanitizer (no parameters)
    #[sanitizer(TrimSanitizer)]
    pub name: Text,

    // Tuple struct sanitizer (positional parameter)
    #[sanitizer(RoundToScaleSanitizer(2))]
    pub balance: Decimal,

    // Named fields sanitizer
    #[sanitizer(ClampSanitizer, min = 0, max = 120)]
    pub age: Uint8,
}
}

Attribute Forms

The first argument of #[sanitizer(...)] is always the path of the sanitizer type. The arguments that follow depend on how the sanitizer is declared:

FormSyntaxUse when
Unit#[sanitizer(TrimSanitizer)]The sanitizer is a unit struct with no settings
Tuple#[sanitizer(RoundToScaleSanitizer(2))]The sanitizer is a tuple struct
Named options#[sanitizer(ClampSanitizer, min = 0, max = 9)]The sanitizer is a struct with named fields

Rules:

  • The sanitizer path may be qualified, for example #[sanitizer(wasm_dbms_api::prelude::TrimSanitizer)].
  • Tuple arguments are positional and are passed to the tuple struct in order. Named options are name = value pairs, written after the sanitizer path and separated by commas. Values are Rust expressions, so negative numbers such as min = -100 are allowed.
  • A tuple sanitizer cannot be combined with named options: #[sanitizer(RoundToScaleSanitizer(2), scale = 3)] is rejected.
  • The sanitizer path must come first. #[sanitizer(min = 0, ClampSanitizer)] is rejected.
  • Every named option must be an assignment. #[sanitizer(ClampSanitizer, 5)] is rejected.
  • An empty attribute, #[sanitizer()], is rejected.
  • Invalid attributes fail at compile time with an error pointing at the offending tokens.
  • A field can have several #[sanitizer(...)] attributes. They run in the order they are written, as described in Sanitization Order.

Built-in Sanitizers

All sanitizers are available in wasm_dbms_api::prelude.

String Sanitizers

TrimSanitizer - Remove leading/trailing whitespace

#![allow(unused)]
fn main() {
#[sanitizer(TrimSanitizer)]
pub name: Text,
// "  Alice  " → "Alice"
}

CollapseWhitespaceSanitizer - Collapse multiple spaces into one

#![allow(unused)]
fn main() {
#[sanitizer(CollapseWhitespaceSanitizer)]
pub description: Text,
// "Hello    World" → "Hello World"
}

LowerCaseSanitizer - Convert to lowercase

#![allow(unused)]
fn main() {
#[sanitizer(LowerCaseSanitizer)]
pub email: Text,
// "Alice@Example.COM" → "alice@example.com"
}

UpperCaseSanitizer - Convert to uppercase

#![allow(unused)]
fn main() {
#[sanitizer(UpperCaseSanitizer)]
pub country_code: Text,
// "us" → "US"
}

SlugSanitizer - Convert to URL-safe slug

#![allow(unused)]
fn main() {
#[sanitizer(SlugSanitizer)]
pub slug: Text,
// "Hello World! This is a Test" → "hello-world-this-is-a-test"
}

UrlEncodingSanitizer - URL encode special characters

#![allow(unused)]
fn main() {
#[sanitizer(UrlEncodingSanitizer)]
pub path: Text,
// "hello world" → "hello%20world"
}

Numeric Sanitizers

RoundToScaleSanitizer - Round decimal to specific precision

#![allow(unused)]
fn main() {
#[sanitizer(RoundToScaleSanitizer(2))]
pub price: Decimal,
// 19.999 → 20.00
// 19.994 → 19.99
}

ClampSanitizer - Clamp value to range (signed)

#![allow(unused)]
fn main() {
#[sanitizer(ClampSanitizer, min = -100, max = 100)]
pub temperature: Int32,
// 150 → 100
// -150 → -100
}

ClampUnsignedSanitizer - Clamp value to range (unsigned)

#![allow(unused)]
fn main() {
#[sanitizer(ClampUnsignedSanitizer, min = 0, max = 100)]
pub percentage: Uint8,
// 150 → 100
// 0 → 0
}

ClampSanitizer applies to Int8, Int16, Int32, and Int64 values, while ClampUnsignedSanitizer applies to Uint8, Uint16, Uint32, and Uint64 values. Bounds outside the range of the column’s integer type are saturated to that range: clamping an Int8 with min = -1000, max = 1000 keeps every value, and clamping it with min = 1000, max = 2000 yields 127.

DateTime Sanitizers

TimezoneSanitizer - Convert to specific timezone

#![allow(unused)]
fn main() {
#[sanitizer(TimezoneSanitizer(-300))] // UTC-05:00, offset in minutes
pub local_time: DateTime,
}

The target offset and the offset of the value must both be less than one day, within -1439..=1439 minutes; a value outside that range is rejected with DbmsError::Sanitize.

UtcSanitizer - Convert to UTC

#![allow(unused)]
fn main() {
#[sanitizer(UtcSanitizer)]
pub timestamp: DateTime,
// Any timezone → UTC
}

Null Sanitizers

NullIfEmptySanitizer - Convert empty strings to null

#![allow(unused)]
fn main() {
#[sanitizer(NullIfEmptySanitizer)]
pub bio: Nullable<Text>,
// "" → Null
// "Hello" → "Hello"
}

Implementing Custom Sanitizers

Create a struct implementing the Sanitize trait:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{Sanitize, Value, DbmsResult};

/// Capitalizes the first letter of each word
pub struct TitleCaseSanitizer;

impl Sanitize for TitleCaseSanitizer {
    fn sanitize(&self, value: Value) -> DbmsResult<Value> {
        match value {
            Value::Text(text) => {
                let title_case = text
                    .as_str()
                    .split_whitespace()
                    .map(|word| {
                        let mut chars = word.chars();
                        match chars.next() {
                            None => String::new(),
                            Some(first) => {
                                first.to_uppercase().to_string() +
                                chars.as_str().to_lowercase().as_str()
                            }
                        }
                    })
                    .collect::<Vec<_>>()
                    .join(" ");
                Ok(Value::Text(title_case.into()))
            }
            other => Ok(other),  // Pass through non-text values
        }
    }
}

// Usage
#[sanitizer(TitleCaseSanitizer)]
pub title: Text,
// "hello world" → "Hello World"
}

Custom sanitizer with parameters:

#![allow(unused)]
fn main() {
/// Truncates string to max length
pub struct TruncateSanitizer(pub usize);

impl Sanitize for TruncateSanitizer {
    fn sanitize(&self, value: Value) -> DbmsResult<Value> {
        match value {
            Value::Text(text) => {
                let truncated: String = text.as_str().chars().take(self.0).collect();
                Ok(Value::Text(truncated.into()))
            }
            other => Ok(other),
        }
    }
}

// Usage
#[sanitizer(TruncateSanitizer(100))]
pub summary: Text,
// "very long text..." → truncated to 100 chars
}

Custom sanitizer with named parameters:

#![allow(unused)]
fn main() {
/// Replaces a pattern with replacement
pub struct ReplaceSanitizer {
    pub pattern: &'static str,
    pub replacement: &'static str,
}

impl Sanitize for ReplaceSanitizer {
    fn sanitize(&self, value: Value) -> DbmsResult<Value> {
        match value {
            Value::Text(text) => {
                let replaced = text.as_str().replace(self.pattern, self.replacement);
                Ok(Value::Text(replaced.into()))
            }
            other => Ok(other),
        }
    }
}

// Usage
#[sanitizer(ReplaceSanitizer, pattern = "\n", replacement = " ")]
pub single_line: Text,
}

Sanitization Order

When multiple sanitizers are applied, they run in declaration order:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    // Order matters!
    #[sanitizer(TrimSanitizer)] // 1. Trim whitespace
    #[sanitizer(CollapseWhitespaceSanitizer)] // 2. Collapse spaces
    #[sanitizer(LowerCaseSanitizer)] // 3. Lowercase
    pub email: Text,
}

// Input: "  Alice@Example.COM  "
// After TrimSanitizer: "Alice@Example.COM"
// After CollapseWhitespaceSanitizer: "Alice@Example.COM" (no change)
// After LowerCaseSanitizer: "alice@example.com"
}

Each sanitizer receives the output of the previous one, and the first error stops the chain. The generated table schema combines them into a SanitizerChain, which you can also build yourself when implementing TableSchema by hand.

Sanitizers run before validators:

#![allow(unused)]
fn main() {
#[sanitizer(TrimSanitizer)]           // 1. Trim
#[validate(MaxStrlenValidator(100))]  // 2. Validate length (after trim)
pub name: Text,
}

Examples

User profile sanitization:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    // Clean up name
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(CollapseWhitespaceSanitizer)]
    pub name: Text,

    // Normalize email
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(LowerCaseSanitizer)]
    pub email: Text,

    // Convert empty to null
    #[sanitizer(NullIfEmptySanitizer)]
    pub bio: Nullable<Text>,

    // Uppercase country code
    #[sanitizer(UpperCaseSanitizer)]
    pub country: Nullable<Text>,
}
}

Financial data sanitization:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "transactions"]
pub struct Transaction {
    #[primary_key]
    pub id: Uuid,

    // Round to cents
    #[sanitizer(RoundToScaleSanitizer(2))]
    pub amount: Decimal,

    // Ensure positive (clamp negatives to 0)
    #[sanitizer(ClampUnsignedSanitizer, min = 0, max = 1000000)]
    pub fee: Uint32,

    // Always store in UTC
    #[sanitizer(UtcSanitizer)]
    pub timestamp: DateTime,
}
}

Content sanitization:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "articles"]
pub struct Article {
    #[primary_key]
    pub id: Uuid,

    // Clean title
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(CollapseWhitespaceSanitizer)]
    pub title: Text,

    // Generate URL-safe slug
    #[sanitizer(SlugSanitizer)]
    pub slug: Text,

    // Clean up content
    #[sanitizer(TrimSanitizer)]
    pub content: Text,

    // Optional summary
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(NullIfEmptySanitizer)]
    pub summary: Nullable<Text>,
}
}

Combined sanitization and validation:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "products"]
pub struct Product {
    #[primary_key]
    pub id: Uuid,

    // Sanitize then validate
    #[sanitizer(TrimSanitizer)]
    #[sanitizer(CollapseWhitespaceSanitizer)]
    #[validate(RangeStrlenValidator(1, 200))]
    pub name: Text,

    // Sanitize price to 2 decimals, no validation needed
    #[sanitizer(RoundToScaleSanitizer(2))]
    pub price: Decimal,

    // Create slug and validate format
    #[sanitizer(SlugSanitizer)]
    #[validate(KebabCaseValidator)]
    #[validate(MaxStrlenValidator(100))]
    pub slug: Text,

    // Clean URL and validate format
    #[sanitizer(TrimSanitizer)]
    #[validate(UrlValidator)]
    pub image_url: Nullable<Text>,
}
}

JSON Reference


Overview

The Json data type allows you to store and query semi-structured JSON data within your database tables. This is useful for:

  • Flexible schemas where structure varies between records
  • Metadata storage
  • User preferences and settings
  • Any scenario where data structure may evolve

Defining JSON Columns

To use JSON in your schema, use the Json type:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    pub metadata: Json,           // Required JSON field
    pub settings: Nullable<Json>, // Optional JSON field
}
}

Creating JSON Values

From string:

#![allow(unused)]
fn main() {
use std::str::FromStr;
use wasm_dbms_api::prelude::Json;

let json = Json::from_str(r#"{"name": "Alice", "age": 30}"#).unwrap();
}

From serde_json::Value:

#![allow(unused)]
fn main() {
use serde_json::json;
use wasm_dbms_api::prelude::Json;

let json: Json = json!({
    "name": "Alice",
    "age": 30,
    "tags": ["developer", "rust"],
    "address": {
        "city": "New York",
        "country": "US"
    }
}).into();
}

In insert requests:

#![allow(unused)]
fn main() {
let user = UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    metadata: json!({
        "role": "admin",
        "permissions": ["read", "write", "delete"]
    }).into(),
    settings: Nullable::Value(json!({
        "theme": "dark",
        "notifications": true
    }).into()),
};
}

JSON Filtering

wasm-dbms provides powerful JSON filtering through the JsonFilter enum:

  • Contains: Check if JSON contains a pattern (structural containment)
  • Extract: Extract value at path and compare
  • HasKey: Check if a path exists

Path Syntax

Paths use dot notation with bracket array indices:

PathMeaning
"name"Root-level field name
"user.name"Nested field at user.name
"items[0]"First element of items array
"users[0].name"name field of first user
"data[0][1]"Nested array access
"[0]"First element of root array

Path examples:

{
  "name": "Alice",           // Path: "name"
  "user": {
    "email": "a@b.com"       // Path: "user.email"
  },
  "tags": ["a", "b", "c"],   // Path: "tags[0]" = "a"
  "matrix": [[1,2], [3,4]]   // Path: "matrix[1][0]" = 3
}

Filter Operations

Contains (Structural Containment)

Checks if the JSON column contains a specified pattern. Implements PostgreSQL @> style containment:

  • Objects: All key-value pairs in pattern must exist in target (recursive)
  • Arrays: All elements in pattern must exist in target (order-independent)
  • Primitives: Must be equal
#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;
use std::str::FromStr;

// Filter where metadata contains {"active": true}
let pattern = Json::from_str(r#"{"active": true}"#).unwrap();
let filter = Filter::json("metadata", JsonFilter::contains(pattern));
}

Containment behavior:

TargetPatternResult
{"a": 1, "b": 2}{"a": 1}Match
{"a": 1}{"a": 1, "b": 2}No match
{"user": {"name": "Alice", "age": 30}}{"user": {"name": "Alice"}}Match
[1, 2, 3][3, 1]Match (order-independent)
[1, 2][1, 2, 3]No match
{"tags": ["a", "b", "c"]}{"tags": ["b"]}Match

Use cases:

  • Check if user has specific role: contains({"role": "admin"})
  • Check if array contains value: contains({"tags": ["important"]})
  • Check nested properties: contains({"settings": {"theme": "dark"}})

Extract (Path Extraction + Comparison)

Extract a value at path and apply comparison:

#![allow(unused)]
fn main() {
// Equal
let filter = Filter::json("metadata",
    JsonFilter::extract_eq("user.name", Value::Text("Alice".into()))
);

// Greater than
let filter = Filter::json("metadata",
    JsonFilter::extract_gt("user.age", Value::Int64(18.into()))
);

// In list
let filter = Filter::json("metadata",
    JsonFilter::extract_in("status", vec![
        Value::Text("active".into()),
        Value::Text("pending".into()),
    ])
);

// Is null (path doesn't exist or value is null)
let filter = Filter::json("metadata",
    JsonFilter::extract_is_null("deleted_at")
);

// Not null (path exists and value is not null)
let filter = Filter::json("metadata",
    JsonFilter::extract_not_null("email")
);
}

Available comparison methods:

MethodDescription
extract_eq(path, value)Equal
extract_ne(path, value)Not equal
extract_gt(path, value)Greater than
extract_lt(path, value)Less than
extract_ge(path, value)Greater than or equal
extract_le(path, value)Less than or equal
extract_in(path, values)Value in list
extract_is_null(path)Path doesn’t exist or is null
extract_not_null(path)Path exists and is not null

extract_is_null checks for a JSON null inside a stored document. A nullable Json column holding NULL matches no JSON filter, including extract_is_null. Use Filter::is_null to find those rows.

HasKey (Path Existence)

Check if a path exists in the JSON:

#![allow(unused)]
fn main() {
// Check for root-level key
let filter = Filter::json("metadata", JsonFilter::has_key("email"));

// Check for nested path
let filter = Filter::json("metadata", JsonFilter::has_key("user.address.city"));

// Check for array element
let filter = Filter::json("metadata", JsonFilter::has_key("items[0]"));
}

Note: HasKey returns true even if the value at path is null. It only checks for path existence.


Combining JSON Filters

JSON filters combine with other filters using and(), or(), not():

#![allow(unused)]
fn main() {
// has email AND age > 18
let filter = Filter::json("metadata", JsonFilter::has_key("email"))
    .and(Filter::json("metadata", JsonFilter::extract_gt("age", Value::Int64(18.into()))));

// role = "admin" OR role = "moderator"
let filter = Filter::json("metadata", JsonFilter::extract_eq("role", Value::Text("admin".into())))
    .or(Filter::json("metadata", JsonFilter::extract_eq("role", Value::Text("moderator".into()))));

// Combine with regular filters
let pattern = Json::from_str(r#"{"active": true}"#).unwrap();
let filter = Filter::eq("id", Value::Int32(1.into()))
    .and(Filter::json("metadata", JsonFilter::contains(pattern)));

// NOT has deleted_at
let filter = Filter::json("metadata", JsonFilter::has_key("deleted_at")).not();
}

Type Conversion

When extracting JSON values, they’re converted to DBMS types:

JSON TypeDBMS Value
nullValue::Null
true/falseValue::Boolean
Integer numberValue::Int64
Float numberValue::Decimal
StringValue::Text
ArrayValue::Json
ObjectValue::Json

A number too large to fit a Decimal, such as 1e100, is extracted as a Value::Json holding that number. It is still a present, non-null value: has_key and extract_not_null match it, and extract_is_null does not.

Numeric comparisons use the numeric magnitude. An extracted number compared with any integer value (Int8 to Int64, Uint8 to Uint64) or with a Decimal matches as you would expect from the numbers alone: 1.5 is greater than Value::Int64(1), and 1 is equal to Value::Decimal(1.0). This applies to Eq, Ne, Gt, Lt, Ge, Le and In.

Comparison examples:

#![allow(unused)]
fn main() {
// JSON: {"count": 42}
// Extracted as Int64, compare with Int64
JsonFilter::extract_eq("count", Value::Int64(42.into()))

// JSON: {"price": 19.99}
// Extracted as Decimal, compare with Decimal
JsonFilter::extract_gt("price", Value::Decimal(10.0.into()))

// JSON: {"active": true}
// Extracted as Boolean
JsonFilter::extract_eq("active", Value::Boolean(true))

// JSON: {"name": "Alice"}
// Extracted as Text
JsonFilter::extract_eq("name", Value::Text("Alice".into()))
}

Complete Example

#![allow(unused)]
fn main() {
use std::str::FromStr;

use wasm_dbms_api::prelude::*;

#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "products"]
pub struct Product {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    pub attributes: Json, // {"color": "red", "size": "M", "tags": ["sale", "new"], "price": 29.99}
}

fn example_queries(database: &impl Database) -> Result<(), Box<dyn std::error::Error>> {
    // Find all red products
    let filter = Filter::json(
        "attributes",
        JsonFilter::extract_eq("color", Value::Text("red".into())),
    );
    let query = Query::builder().filter(filter).build();
    let red_products = database.select::<Product>(query)?;

    // Find products with "sale" tag
    let pattern = Json::from_str(r#"{"tags": ["sale"]}"#)?;
    let filter = Filter::json("attributes", JsonFilter::contains(pattern));
    let query = Query::builder().filter(filter).build();
    let sale_products = database.select::<Product>(query)?;

    // Find products with size attribute
    let filter = Filter::json("attributes", JsonFilter::has_key("size"));
    let query = Query::builder().filter(filter).build();
    let sized_products = database.select::<Product>(query)?;

    // Find red products with price > 20
    let filter = Filter::json(
        "attributes",
        JsonFilter::extract_eq("color", Value::Text("red".into())),
    )
    .and(Filter::json(
        "attributes",
        JsonFilter::extract_gt("price", Value::Decimal(20.0.into())),
    ));
    let query = Query::builder().filter(filter).build();
    let expensive_red = database.select::<Product>(query)?;

    // Find products in specific sizes
    let filter = Filter::json(
        "attributes",
        JsonFilter::extract_in(
            "size",
            vec![
                Value::Text("S".into()),
                Value::Text("M".into()),
                Value::Text("L".into()),
            ],
        ),
    );
    let query = Query::builder().filter(filter).build();
    let standard_sizes = database.select::<Product>(query)?;

    Ok(())
}
}

Error Handling

JSON filter operations return errors for:

Invalid path syntax:

  • Empty paths
  • Trailing dots ("user.")
  • Unclosed brackets ("items[0")
  • Negative indices ("items[-1]")
  • Non-numeric array indices ("items[abc]")

Non-JSON column:

  • Applying JSON filter to a non-JSON column
#![allow(unused)]
fn main() {
// Invalid path - will error
let filter = Filter::json("metadata", JsonFilter::has_key("user."));  // Trailing dot

// Non-JSON column - will error
let filter = Filter::json("name", JsonFilter::has_key("field"));  // "name" is Text, not Json

let result = database.select::<User>(
    Query::builder().filter(filter).build(),
);

match result {
    Err(DbmsError::Query(QueryError::InvalidQuery)) => {
        println!("Invalid JSON filter");
    }
    _ => {}
}
}

Schema Migrations


Overview

Schema migrations let #[derive(Table)] schemas evolve across releases without losing data or requiring manual stable-memory surgery. The framework:

  1. Stores a TableSchemaSnapshot for every registered table on disk.
  2. Hashes those snapshots into a single schema_hash cached on Page 0 of the schema registry.
  3. On every boot, recomputes the hash from the compiled schema and compares it against the stored hash to detect drift.
  4. Refuses CRUD while in drift state and waits for an explicit dbms.migrate(policy) call.
  5. Applies the diff between stored and compiled snapshots transactionally via the journaled writer.

Migrations are forward-only and explicit. The DBMS never auto-migrates on init — the caller decides when (and whether) to run them.


Lifecycle

┌─────────────────────────────────────────────────────────┐
│  boot / post_upgrade                                    │
│    ├─ load SchemaRegistry (Page 0) → read schema_hash   │
│    ├─ compute current_hash from compiled schemas        │
│    └─ drift = (stored_hash != current_hash)             │
├─────────────────────────────────────────────────────────┤
│  drift == false                                         │
│    ├─ CRUD allowed                                      │
│    └─ migrate() is a no-op                              │
├─────────────────────────────────────────────────────────┤
│  drift == true                                          │
│    ├─ CRUD returns DbmsError::Migration(SchemaDrift)    │
│    ├─ pending_migrations() → Vec<MigrationOp>               │
│    └─ migrate(policy) applies ops, clears drift         │
└─────────────────────────────────────────────────────────┘

Performance contract:

  • Boot: one u64 read from Page 0 plus one xxh3 hash of the encoded compiled snapshots. O(tables × columns).
  • Hot path (CRUD): a single bool load (drift flag on the DBMS context) plus a branch. No snapshot decode, no hash recompute.
  • Snapshot decode: only on pending_migrations() or migrate(). Never during CRUD.

Drift Detection

Drift is the only signal the DBMS uses to decide whether migration is required. It is computed once on boot:

  1. Load SchemaRegistry from Page 0.
  2. For each table in DatabaseSchema::compiled_snapshots(), encode the snapshot.
  3. Compute current_hash = xxh3(sorted-by-name concatenation of encoded bytes).
  4. drift = (schema_registry.schema_hash != current_hash).
  5. Cache drift: bool on the DBMS context.

Every CRUD entry point early-returns Err(DbmsError::Migration(MigrationError::SchemaDrift)) while drift == true.


Schema Snapshots

A snapshot is a self-describing, versioned view of a table’s compile-time shape. It captures only what is meaningful for migration; transient or derivable fields are intentionally omitted.

#![allow(unused)]
fn main() {
pub struct TableSchemaSnapshot {
    pub version: u8, // bumped on any breaking layout change
    pub name: String,
    pub primary_key: String,
    pub alignment: u32,
    pub columns: Vec<ColumnSnapshot>, // declaration order preserved
    pub indexes: Vec<IndexSnapshot>,
}

pub struct ColumnSnapshot {
    pub name: String,
    pub data_type: DataTypeSnapshot,
    pub nullable: bool,
    pub auto_increment: bool,
    pub unique: bool,
    pub primary_key: bool,
    pub foreign_key: Option<ForeignKeySnapshot>,
    pub default: Option<Value>,
}

#[repr(u8)]
pub enum DataTypeSnapshot {
    Int8 = 0x01,
    Int16 = 0x02,
    Int32 = 0x03,
    Int64 = 0x04,
    Uint8 = 0x10,
    Uint16 = 0x11,
    Uint32 = 0x12,
    Uint64 = 0x13,
    Float32 = 0x20,
    Float64 = 0x21,
    Decimal = 0x22,
    Boolean = 0x30,
    Date = 0x40,
    Datetime = 0x41,
    Blob = 0x50,
    Text = 0x51,
    Uuid = 0x52,
    Json = 0x60,
    Custom { tag: String, wire_size: WireSize } = 0xF0,
}

pub enum WireSize {
    Fixed(u32),     // column occupies exactly N bytes
    LengthPrefixed, // body preceded by 2-byte LE length prefix
}
}

WireSize is derived at compile time from the custom type’s Encode::SIZE: DataSize::Fixed(n) → WireSize::Fixed(n), DataSize::Dynamic → WireSize::LengthPrefixed. The migration codec uses it to slice column bytes during a snapshot-driven rewrite without invoking the user’s Encode::decode impl.

Stability rules:

  1. DataTypeSnapshot discriminants are frozen. Never reorder, never reuse a removed slot.
  2. Adding a field appends at the tail and bumps the container version. Old readers stop at the previous length prefix.
  3. Removing a field leaves the slot reserved. Do not shift later fields.
  4. Wire format per struct: length-prefix + field-by-field little-endian. String = u16 length + UTF-8 bytes. Option = u8 flag + body. Vec = u32 length + entries.

The snapshot encoder enforces hard caps on identifier lengths and table shape — see the Schema Definition warning for the full list. Names exceeding 255 bytes will truncate or panic at runtime.


Migration Plan

The planner takes two inputs:

  • stored: Vec<TableSchemaSnapshot> — read from each table’s snapshot page.
  • compiled: Vec<TableSchemaSnapshot> — built from compile-time TableSchema::schema_snapshot().

Tables match by exact, case-sensitive name. The diff produces three buckets:

  • compiled \ stored → CreateTable.
  • stored ∩ compiled → per-table column + index diff (see below).
  • stored \ compiled → DropTable.

Column diff (per matched table):

For each compiled column:

  1. Look up the stored column by name. Match → step 3.
  2. On miss, walk the compiled column’s renamed_from slice. The first stored column hit emits RenameColumn; continue at step 3 with the renamed stored column.
  3. Compare (data_type, nullable, auto_increment, unique, primary_key, foreign_key):
    • Types differ and the change is in the widening whitelist → WidenColumn.
    • Types differ and Migrate::transform_column returns a non-trivial override → TransformColumn.
    • Types differ and neither applies → MigrationError::IncompatibleType.
    • Any constraint flag changed → AlterColumn { changes }.

Stored columns not matched by any compiled column (directly or via renamed_from) → DropColumn. Compiled columns not matched → AddColumn. If non-nullable, the planner requires either #[default = ...] or Migrate::default_value returning Some, otherwise MigrationError::DefaultMissing.

Index diff:

Indexes are matched by (sorted column list, unique) tuple. Differences emit AddIndex / DropIndex.

MigrationOp

#![allow(unused)]
fn main() {
pub enum MigrationOp {
    CreateTable {
        name: String,
        schema: TableSchemaSnapshot,
    },
    DropTable {
        name: String,
    }, // destructive
    AddColumn {
        table: String,
        column: ColumnSnapshot,
    },
    DropColumn {
        table: String,
        column: String,
    }, // destructive
    RenameColumn {
        table: String,
        old: String,
        new: String,
    },
    AlterColumn {
        table: String,
        column: String,
        changes: ColumnChanges,
    },
    WidenColumn {
        table: String,
        column: String,
        old_type: DataTypeSnapshot,
        new_type: DataTypeSnapshot,
    },
    TransformColumn {
        table: String,
        column: String,
        old_type: DataTypeSnapshot,
        new_type: DataTypeSnapshot,
    },
    AddIndex {
        table: String,
        index: IndexSnapshot,
    },
    DropIndex {
        table: String,
        index: IndexSnapshot,
    },
}

pub struct ColumnChanges {
    pub nullable: Option<bool>,
    pub unique: Option<bool>,
    pub auto_increment: Option<bool>,
    pub primary_key: Option<bool>,
    pub foreign_key: Option<Option<ForeignKeySnapshot>>, // Some(None) = drop FK
}
}

In JSON, ColumnChanges::foreign_key has three distinct forms:

  • the field is omitted when the foreign key is unchanged;
  • null when the foreign key is dropped;
  • a foreign-key snapshot object when it is added or replaced.

This representation targets JSON and other self-describing, human-readable formats. Candid uses its own encoding, which keeps both option layers.

Apply Order

Ops are sorted into a deterministic order so an AddColumn referencing a new FK target finds its target table already created, and so tightenings run only after data is in place:

  1. CreateTable — new FK targets must exist first.
  2. DropIndex.
  3. DropColumn.
  4. RenameColumn.
  5. AlterColumn — relaxations only (nullable: true, unique: false, drop FK).
  6. WidenColumn.
  7. TransformColumn.
  8. AddColumn.
  9. AlterColumn — tightenings (nullable: false, unique: true, add FK). The planner validates existing data; offending rows trigger MigrationError::ConstraintViolation.
  10. AddIndex.
  11. DropTable.

All ops execute inside a single JournaledWriter session. Any failure rolls back every page touched; stored snapshots, schema_hash, and the drift flag are not mutated on failure.

Commit step (on success):

  1. Write each updated TableSchemaSnapshot to its schema_snapshot_page.
  2. Recompute schema_hash and write to SchemaRegistry on Page 0.
  3. Clear the in-memory drift flag.

All three writes live in the same journal session as the data rewrites, so partial migrations are impossible.

Pre-flight validation: before opening the journal session, the planner runs pending_migrations(), checks MigrationPolicy, and verifies each op is applicable (AddColumn has a default or is nullable, type changes are widenings or have a transform, etc.). Errors in this phase do not touch memory.


Compatible Widening Whitelist

Auto-applied without user code. The framework rewrites records in place.

From → ToSemantics
IntN → IntM, M > Nsign-extend
UintN → UintM, M > Nzero-extend
UintN → IntM, M > Nzero-extend into signed
Float32 → Float64widen

Everything else (narrowing, sign flips, int↔float, int↔text, etc.) falls through to TransformColumn or errors with MigrationError::IncompatibleType.


Per-Table Hooks

Three macro features feed the planner. They produce no runtime cost on CRUD.

#[default] Attribute

Static per-column default for AddColumn ops on non-nullable columns.

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,

    #[default = 0]
    pub login_count: Uint32,
}
}

The expression must convert into the column’s Value variant via From/Into. See the Default Value section in the schema reference for the full rules.

#[renamed_from] Attribute

Lists previous names for a column so the planner can emit RenameColumn instead of DropColumn + AddColumn:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,

    #[renamed_from("username", "user_name")]
    pub name: Text,
}
}

Multiple entries support recovery from skipped releases. See the Renamed From section in the schema reference.

Migrate Trait

#[derive(Table)] emits an empty impl Migrate for T {} for every table by default. Override it by adding #[migrate] at the struct level and writing the impl yourself:

#![allow(unused)]
fn main() {
pub trait Migrate
where
    Self: TableSchema,
{
    /// Dynamic default for AddColumn on a non-nullable column.
    /// `None` falls back to the static `#[default]` attribute, else
    /// DefaultMissing.
    fn default_value(_column: &str) -> Option<Value> {
        None
    }

    /// Transform a stored value during an incompatible type change.
    /// `Ok(None)` → no transform (errors unless widening applies).
    /// `Ok(Some(v))` → use `v`.
    /// `Err(_)` → abort migration; journal rolls back.
    fn transform_column(_column: &str, _old: Value) -> DbmsResult<Option<Value>> {
        Ok(None)
    }
}
}

See the Migrate Override section in the schema reference for usage examples.


Migration Policy

#![allow(unused)]
fn main() {
pub struct MigrationPolicy {
    pub allow_destructive: bool, // DropTable, DropColumn
}

impl Default for MigrationPolicy {
    fn default() -> Self {
        Self {
            allow_destructive: false,
        }
    }
}
}

The default policy refuses destructive ops. Pre-flight planning emits MigrationError::DestructiveOpDenied { op } if any DropTable or DropColumn op is present and allow_destructive is false.

#![allow(unused)]
fn main() {
// Allow drops
dbms.migrate(MigrationPolicy { allow_destructive: true })?;
}

Errors

DbmsError::Migration(MigrationError) covers the full migration pipeline:

VariantWhen
SchemaDriftCRUD called while drift == true. Call migrate(policy) first.
IncompatibleTypeType change is neither in the widening whitelist nor handled by transform_column.
DefaultMissingAddColumn on a non-nullable column without #[default] or default_value override.
ConstraintViolationTightening op found data that violates the new constraint.
DestructiveOpDeniedPlanner emitted DropTable / DropColumn while allow_destructive is false.
TransformAbortedUser transform_column impl returned Err.
WideningIncompatibleWidenColumn op falls outside the widening whitelist (and no transform_column impl handled it).
TransformReturnedNoneMigrate::transform_column returned Ok(None) while a transform was required.
ForeignKeyViolationAdd-FK tightening found a row whose value is absent from the target table’s column.

See the Migration Errors section in the errors reference for matching examples and remediation.


API Surface

Database Trait

The migration methods are part of the Database trait, implemented by WasmDbmsDatabase:

#![allow(unused)]
fn main() {
pub trait Database {
    // ... CRUD, query, and transaction methods ...

    /// O(1) after the first call. True iff compiled schema differs from stored.
    fn has_drift(&self) -> DbmsResult<bool>;

    /// Compute the diff without applying. Safe to call during drift.
    fn pending_migrations(&self) -> DbmsResult<Vec<MigrationOp>>;

    /// Apply the diff. Transactional. Errors leave the database unchanged.
    fn migrate(&mut self, policy: MigrationPolicy) -> DbmsResult<()>;
}
}

DatabaseSchema Dispatch

#[derive(DatabaseSchema)] emits three migration dispatch methods alongside the CRUD dispatch methods:

#![allow(unused)]
fn main() {
pub trait DatabaseSchema<M>
where
    M: MemoryProvider,
{
    // ... existing CRUD dispatch ...

    fn migrate_default(table: &str, column: &str) -> Option<Value>
    where
        Self: Sized;

    fn migrate_transform(table: &str, column: &str, old: Value) -> DbmsResult<Option<Value>>
    where
        Self: Sized;

    fn compiled_snapshots() -> Vec<TableSchemaSnapshot>
    where
        Self: Sized;
}
}

The macro generates match arms keyed by table name. migrate_default chains Migrate::default_value → ColumnDef::default; migrate_transform dispatches to Migrate::transform_column; compiled_snapshots calls T::schema_snapshot() for every table in the #[tables(...)] list.

For the migration endpoints exposed on the Internet Computer, see the ic-dbms documentation.


Non-Goals

The following are intentionally out of scope:

  • Table rename. Detect via renamed_from on columns; full table rename requires manual migration.
  • Custom data type binary evolution. User-defined types are keyed by name; binary layout stability remains the user’s responsibility.
  • Downgrade / rollback to an older schema. Migrations are forward-only. Failed migrations roll back to the pre-migration state, but there is no path from a newer snapshot to an older compiled schema.
  • Automatic migration on DB init. Migration is explicit, triggered by the operator.

Worked Example

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::*;

// Release v1
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct UserV1 {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
}

// Release v2: rename `name` → `full_name`, add a non-nullable
// `login_count` column with a default of 0, and keep an index on
// `full_name`.
#[derive(Debug, Table, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct UserV2 {
    #[primary_key]
    pub id: Uint32,

    #[renamed_from("name")]
    #[index]
    pub full_name: Text,

    #[default = 0]
    pub login_count: Uint32,
}
}

After deploying the new binary, dbms.has_drift() returns true. Calling dbms.migrate(MigrationPolicy::default()) produces the following ops (in apply order):

  1. RenameColumn { table: "users", old: "name", new: "full_name" }
  2. AddColumn { table: "users", column: ColumnSnapshot { name: "login_count", default: Some(Value::Uint32(Uint32(0))), ... } }
  3. AddIndex { table: "users", index: IndexSnapshot { columns: vec!["full_name".into()], unique: false } }

The session commits atomically; existing rows now carry login_count = 0 and the rename preserves their stored values.


Best Practices

1. Land schema changes one release at a time.

Combining a rename, a tightening, and a non-nullable add in one release multiplies the chance of ConstraintViolation mid-apply. Stage each kind in its own release where feasible.

2. Tighten only after backfilling.

Plan a nullable: false flip in two steps: first add the column nullable + backfill, then tighten in the next release. This isolates MigrationError::ConstraintViolation to a release where the cause is obvious.

3. Always start with allow_destructive: false.

Run pending_migrations() and inspect the ops before flipping the policy. A surprise DropTable because of a typo in #[table = "..."] is much cheaper to catch in pre-flight than after the journal commits.

4. Test drift with the real binary format.

Hand-rolled snapshots in tests are risky because the encoder is the source of truth for the wire format. Roundtrip via Encode::encode / Encode::decode and assert equality.

5. Treat DataTypeSnapshot discriminants as frozen.

Adding a new variant takes a fresh tag. Renaming or reordering existing tags breaks every snapshot in production.

6. Persist migration logs externally.

The DBMS does not retain a history of applied migrations beyond the new schema_hash. If you need an audit trail, log pending_migrations() output before calling migrate().

Errors Reference


Overview

wasm-dbms uses a structured error system to provide clear information about what went wrong. Errors are categorized by their source:

CategoryDescription
QueryDatabase operation errors (constraints, missing data)
TransactionTransaction state errors
ValidationData validation failures
SanitizationData sanitization failures
MemoryLow-level memory errors
MigrationSchema migration / drift detection errors
TableSchema/table definition errors
SQLErrors of the SQL engine (SqlError, sql feature)

Error Hierarchy

DbmsError
├── Query(QueryError)
│   ├── PrimaryKeyConflict
│   ├── UniqueConstraintViolation
│   ├── BrokenForeignKeyReference
│   ├── ForeignKeyConstraintViolation
│   ├── UnknownColumn
│   ├── MissingNonNullableField
│   ├── RecordNotFound
│   └── InvalidQuery
├── Transaction(TransactionError)
│   └── NotFound
├── Validation(String)
├── Sanitize(String)
├── Memory(MemoryError)
├── Migration(MigrationError)
│   ├── SchemaDrift
│   ├── IncompatibleType { table, column, old, new }
│   ├── DefaultMissing { table, column }
│   ├── ConstraintViolation { table, column, reason }
│   ├── DestructiveOpDenied { op }
│   ├── TransformAborted { table, column, reason }
│   ├── WideningIncompatible { table, column, old_type, new_type }
│   ├── TransformReturnedNone { table, column }
│   └── ForeignKeyViolation { table, column, target_table, value }
└── Table(TableError)

DbmsError

The top-level error enum:

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::DbmsError;

pub enum DbmsError {
    Memory(MemoryError),
    Migration(MigrationError),
    Query(QueryError),
    Table(TableError),
    Transaction(TransactionError),
    Sanitize(String),
    Validation(String),
}
}

Matching on error types:

#![allow(unused)]
fn main() {
match error {
    DbmsError::Query(query_err) => {
        // Handle query errors
    }
    DbmsError::Transaction(tx_err) => {
        // Handle transaction errors
    }
    DbmsError::Validation(msg) => {
        // Handle validation errors
        println!("Validation failed: {}", msg);
    }
    DbmsError::Sanitize(msg) => {
        // Handle sanitization errors
        println!("Sanitization failed: {}", msg);
    }
    DbmsError::Memory(mem_err) => {
        // Handle memory errors (rare)
    }
    DbmsError::Migration(mig_err) => {
        // Handle schema migration errors
    }
    DbmsError::Table(table_err) => {
        // Handle table errors (rare)
    }
}
}

Migration Errors

MigrationError covers the schema migration pipeline: drift detection on boot, plan validation, and journaled apply. See the Migrations Reference for the full lifecycle.

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{DataTypeSnapshot, MigrationError};

pub enum MigrationError {
    SchemaDrift,
    IncompatibleType {
        table: String,
        column: String,
        old: DataTypeSnapshot,
        new: DataTypeSnapshot,
    },
    DefaultMissing {
        table: String,
        column: String,
    },
    ConstraintViolation {
        table: String,
        column: String,
        reason: String,
    },
    DestructiveOpDenied {
        op: String,
    },
    TransformAborted {
        table: String,
        column: String,
        reason: String,
    },
    WideningIncompatible {
        table: String,
        column: String,
        old_type: DataTypeSnapshot,
        new_type: DataTypeSnapshot,
    },
    TransformReturnedNone {
        table: String,
        column: String,
    },
    ForeignKeyViolation {
        table: String,
        column: String,
        target_table: String,
        value: String,
    },
}
}

SchemaDrift

Cause: A CRUD operation was attempted while the DBMS is in drift state — the compiled schema’s hash differs from the hash stored in the schema registry.

#![allow(unused)]
fn main() {
match database.insert::<User>(req) {
    Err(DbmsError::Migration(MigrationError::SchemaDrift)) => {
        // Stop accepting writes; call dbms.migrate(policy) first.
    }
    _ => {}
}
}

Solutions:

  • Call dbms.migrate(MigrationPolicy::default()) from your boot path to clear the drift flag.
  • Inspect the diff first via dbms.pending_migrations() to confirm the ops are safe.

IncompatibleType

Cause: A column changed to a type that is neither in the widening whitelist (e.g. Int32 → Int64) nor handled by Migrate::transform_column.

Solutions:

  • If the change is conceptually a widen, double-check the from/to types match the whitelist.
  • Otherwise mark the table with #[migrate] and provide a transform_column impl that maps the old Value to the new type.

DefaultMissing

Cause: Planning an AddColumn op for a non-nullable column that has neither a #[default = ...] attribute nor a Migrate::default_value override.

Solutions:

  • Add #[default = <expr>] to the field, or
  • Implement Migrate::default_value for the table (after marking it #[migrate]), or
  • Make the column Nullable<T> so NULL is the implicit default.

ConstraintViolation

Cause: Tightening an existing column (nullable: false, unique: true, add foreign key) on data that violates the new constraint.

Solutions:

  • Clean the data before bumping the schema (e.g. backfill NULLs, deduplicate).
  • Stage the change across two releases: relaxation + cleanup, then tightening.

DestructiveOpDenied

Cause: The planner emitted a DropTable or DropColumn op while MigrationPolicy::allow_destructive is false.

Solutions:

  • Confirm the destruction is intentional and pass MigrationPolicy { allow_destructive: true }.
  • Otherwise re-introduce the missing struct/field in the compiled schema.

TransformAborted

Cause: A user-supplied Migrate::transform_column impl returned Err. The journaled migration session rolls back; stored data and schema_hash are unchanged.

Solutions:

  • Inspect the embedded reason string to see which row failed.
  • Fix the offending data manually (or via a helper routine) before retrying migrate.

WideningIncompatible

Cause: A WidenColumn op named a (old_type, new_type) pair that is not in the widening whitelist, and the table did not provide a Migrate::transform_column impl that handled it. The journaled session rolls back; stored data and schema_hash are unchanged.

Solutions:

  • Pick a target type that fits the whitelist (e.g. Uint32 → Uint64 rather than Uint32 → Uint8).
  • Mark the table #[migrate] and provide a transform_column arm that maps the old Value into the new type.
  • Stage the change across two releases: convert via a transform first, then narrow as a separate widening with valid bounds.

TransformReturnedNone

Cause: Migrate::transform_column returned Ok(None) for a column that needed a transform (no widening rule applied). The migration aborts and rolls back.

Solutions:

  • Implement a concrete Ok(Some(_)) arm for the column in the table’s Migrate impl.
  • Or pick a target type that fits the widening whitelist so the framework converts automatically.

ForeignKeyViolation

Cause: An add-FK tightening (AlterColumn with foreign_key: Some(Some(_))) found a row whose value is absent from the target table’s referenced column. The journaled session rolls back; stored data and schema_hash are unchanged.

Solutions:

  • Clean up the orphan rows in a prior release before adding the FK.
  • Inspect value in the error to identify the offending record(s).

Query Errors

Query errors occur during database operations.

PrimaryKeyConflict

Cause: Attempting to insert a record with a primary key that already exists.

#![allow(unused)]
fn main() {
// Insert first user
database.insert::<User>(UserInsertRequest {
    id: 1.into(),
    name: "Alice".into(),
    ..
})?;

// Insert second user with same ID - FAILS
let result = database.insert::<User>(UserInsertRequest {
    id: 1.into(),  // Same ID!
    name: "Bob".into(),
    ..
});

match result {
    Err(DbmsError::Query(QueryError::PrimaryKeyConflict)) => {
        println!("A user with this ID already exists");
    }
    _ => {}
}
}

Solutions:

  • Use a unique primary key (e.g., UUID)
  • Check if record exists before inserting
  • Use upsert pattern (check, then insert or update)

UniqueConstraintViolation

Cause: Attempting to insert or update a record with a value that violates a #[unique] constraint.

#![allow(unused)]
fn main() {
// Insert first user
database.insert::<User>(UserInsertRequest {
    id: 1.into(),
    email: "alice@example.com".into(),
    ..
})?;

// Insert second user with same email - FAILS
let result = database.insert::<User>(UserInsertRequest {
    id: 2.into(),
    email: "alice@example.com".into(),  // Duplicate!
    ..
});

match result {
    Err(DbmsError::Query(QueryError::UniqueConstraintViolation { field })) => {
        println!("Duplicate value on field: {}", field);
        // field == "email"
    }
    _ => {}
}
}

Also triggered on update:

#![allow(unused)]
fn main() {
// Update user 2's email to match user 1's email - FAILS
let result = database.update::<User>(
    UserUpdateRequest::from_values(
        &[(email_col, Value::Text("alice@example.com".into()))],
        Some(Filter::eq("id", Value::Uint32(2.into()))),
    )?,
);
}

Solutions:

  • Check if a record with the same value exists before inserting
  • Use a different value

BrokenForeignKeyReference

Cause: Foreign key references a record that doesn’t exist.

#![allow(unused)]
fn main() {
// Insert post with non-existent author
let result = database.insert::<Post>(PostInsertRequest {
    id: 1.into(),
    title: "My Post".into(),
    author_id: 999.into(),  // User 999 doesn't exist!
    ..
});

match result {
    Err(DbmsError::Query(QueryError::BrokenForeignKeyReference)) => {
        println!("Referenced user does not exist");
    }
    _ => {}
}
}

Solutions:

  • Ensure referenced record exists before inserting
  • Create referenced record first in a transaction

ForeignKeyConstraintViolation

Cause: Attempting to delete a record that is referenced by other records (with Restrict behavior).

#![allow(unused)]
fn main() {
// User has posts - cannot delete with Restrict
let result = database.delete::<User>(
    DeleteBehavior::Restrict,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
);

match result {
    Err(DbmsError::Query(QueryError::ForeignKeyConstraintViolation)) => {
        println!("Cannot delete: user has related records");
    }
    _ => {}
}
}

Solutions:

  • Delete related records first
  • Use DeleteBehavior::Cascade to delete related records automatically

UnknownColumn

Cause: Referencing a column that doesn’t exist in the table.

#![allow(unused)]
fn main() {
// Filter with wrong column name
let filter = Filter::eq("username", Value::Text("alice".into()));  // Column is "name", not "username"

let result = database.select::<User>(
    Query::builder().filter(filter).build(),
);

match result {
    Err(DbmsError::Query(QueryError::UnknownColumn)) => {
        println!("Column does not exist in table");
    }
    _ => {}
}
}

Solutions:

  • Check column names in your schema
  • Use IDE autocompletion with typed column names

MissingNonNullableField

Cause: Required field not provided in insert/update.

#![allow(unused)]
fn main() {
// This typically happens at compile time with the generated types,
// but can occur if manually constructing requests or using dynamic queries
}

Solutions:

  • Provide all required fields
  • Use Nullable<T> for optional fields

RecordNotFound

Cause: Operation targets a record that doesn’t exist.

#![allow(unused)]
fn main() {
// Update non-existent record
let update = UserUpdateRequest::builder()
    .set_name("New Name".into())
    .filter(Filter::eq("id", Value::Uint32(999.into())))  // Doesn't exist
    .build();

let affected = database.update::<User>(update)?;

// affected == 0 indicates no records matched
if affected == 0 {
    println!("No records found to update");
}
}

Note: Update and delete operations return the count of affected rows. A count of 0 isn’t necessarily an error but indicates no matches.

InvalidQuery

Cause: Malformed query (invalid JSON path, bad filter syntax, etc.).

#![allow(unused)]
fn main() {
// Invalid JSON path
let filter = Filter::json("metadata", JsonFilter::has_key("user."));  // Trailing dot

let result = database.select::<User>(
    Query::builder().filter(filter).build(),
);

match result {
    Err(DbmsError::Query(QueryError::InvalidQuery)) => {
        println!("Query is malformed");
    }
    _ => {}
}
}

Common causes:

  • Invalid JSON paths (trailing dots, unclosed brackets)
  • Applying JSON filter to non-JSON column
  • Type mismatches in comparisons
  • Update patch value whose type does not match its column, built with UpdateRecord::from_values or sent through the dynamic schema update ("column '<col>' does not accept a value of type <type>"); Null is accepted only by nullable columns
  • Aggregate-specific:
    • SUM or AVG on non-numeric column ("aggregate requires numeric column: '<col>'")
    • HAVING references unknown column or agg{N} ("HAVING references unknown column or aggregate: '<col>'")
    • ORDER BY references unknown agg{N} ("ORDER BY references unknown aggregate output: '<col>'")
    • LIKE or JSON filter inside HAVING
    • Joins or eager relations on Database::aggregate

JoinInsideTypedSelect

Cause: A typed Database::select::<T> was called with a query that contains joins. Joins must go through select_join.

AggregateClauseInSelect

Cause: group_by or having was set on a non-aggregate select path (select, select_raw, or select_join). Use Database::aggregate instead — those clauses have no meaning outside aggregation and are rejected to prevent silent data loss.

#![allow(unused)]
fn main() {
let result = database.select::<User>(
    Query::builder().group_by(&["role"]).build(),
);

match result {
    Err(DbmsError::Query(QueryError::AggregateClauseInSelect)) => {
        // call database.aggregate::<User>(query, &aggregates) instead
    }
    _ => {}
}
}

Transaction Errors

TransactionNotFound

Cause: Invalid transaction ID or transaction already completed.

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::{DbmsError, TransactionError};

match database.commit() {
    Err(DbmsError::Transaction(TransactionError::NoActiveTransaction)) => {
        println!("No active transaction to commit");
    }
    _ => {}
}
}

Causes:

  • Transaction ID never existed
  • Transaction was already committed
  • Transaction was already rolled back

Validation Errors

Cause: Data fails validation rules.

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "users"]
pub struct User {
    #[validate(EmailValidator)]
    pub email: Text,
}

// Insert with invalid email
let result = database.insert::<User>(UserInsertRequest {
    id: 1.into(),
    email: "not-an-email".into(),  // Invalid!
    ..
});

match result {
    Err(DbmsError::Validation(msg)) => {
        println!("Validation failed: {}", msg);
        // msg might be: "Invalid email format"
    }
    _ => {}
}
}

Common validation errors:

  • String too long (MaxStrlenValidator)
  • String too short (MinStrlenValidator)
  • Invalid email format (EmailValidator)
  • Invalid URL format (UrlValidator)
  • Invalid phone format (PhoneNumberValidator)

Sanitization Errors

Cause: Sanitizer fails to process the data.

#![allow(unused)]
fn main() {
// Sanitization errors are rare but can occur with malformed data
match result {
    Err(DbmsError::Sanitize(msg)) => {
        println!("Sanitization failed: {}", msg);
    }
    _ => {}
}
}

Sanitization errors are less common than validation errors since sanitizers typically transform data rather than reject it.


Memory Errors

Cause: Low-level memory errors.

#![allow(unused)]
fn main() {
pub enum MemoryError {
    OutOfBounds,           // Read/write outside allocated memory
    ProviderError(String), // Memory provider error
    InsufficientSpace,     // Not enough space to allocate
}
}

Memory errors are rare and usually indicate:

  • Running out of available memory
  • Corrupted memory state
  • Bug in wasm-dbms (please report!)

SQL Errors

Statements run through SqlEngine::execute (crate wasm-dbms-sql) fail with a SqlError. The type lives in wasm-dbms-api behind the sql feature, which wasm-dbms-sql turns on.

#![allow(unused)]
fn main() {
use wasm_dbms_api::prelude::SqlError;

pub enum SqlError {
    Parse {
        line: usize,
        col: usize,
        msg: String,
    },
    UnknownTable(String),
    UnknownColumn {
        table: String,
        column: String,
    },
    AmbiguousColumn(String),
    TypeMismatch {
        column: String,
        expected: CandidDataTypeKind,
        got: String,
    },
    InvalidLiteral {
        column: String,
        expected: CandidDataTypeKind,
        reason: String,
    },
    MissingWhereClause,
    ParameterCountMismatch {
        expected: usize,
        got: usize,
    },
    TransactionAlreadyActive,
    NoActiveTransaction,
    Unsupported(String),
    Runtime(DbmsError),
}
}

Every variant except Runtime is detected before the database is read or written, so a statement that fails with one of them has no effect.

The SQL dialect is described in the SQL Reference.

Parse

Cause: The text is not a statement of the supported grammar. line and col start at 1 and point at the first token that cannot be accepted. msg says what was expected.

#![allow(unused)]
fn main() {
match engine.execute(&ctx, None, "SELECT name FORM users", &[]) {
    Err(SqlError::Parse { line, col, msg }) => {
        // line 1, column 13: expected `FROM`, found identifier `FORM`
        println!("syntax error at {line}:{col}: {msg}");
    }
    _ => {}
}
}

Common causes:

  • A typo in a keyword, or a missing comma or parenthesis
  • A reserved word used as a name without double quotes
  • Several statements in one call
  • A feature that the dialect does not have, such as a subquery or an expression

UnknownTable

Cause: The statement names a table that is not in the schema, or qualifies a column with a name that is not a table or alias of the statement.

Names are case-sensitive. A table that has an alias must be referred to by the alias.

UnknownColumn (SQL)

Cause: The table has no column with that name.

#![allow(unused)]
fn main() {
match engine.execute(&ctx, None, "SELECT nickname FROM users", &[]) {
    Err(SqlError::UnknownColumn { table, column }) => {
        println!("table {table} has no column {column}");
    }
    _ => {}
}
}

AmbiguousColumn

Cause: In a join, a column name without a table qualifier exists in more than one table.

Solution: Qualify the column: users.id instead of id.

TypeMismatch

Cause: A literal or parameter of the wrong kind is used with a column, for example a string compared with an integer column. got names the kind of the literal (Integer, Float, String, Boolean, Null) or the type of the parameter. Incompatible underlying types of columns in a join’s ON condition also cause this error; got is the type of the joined column. For example, users.name = posts.user_id compares Text with Uint32 and reports column posts.user_id, expected Text, got Uint32.

#![allow(unused)]
fn main() {
// `age` is a Uint8 column
match engine.execute(&ctx, None, "SELECT * FROM users WHERE age = 'old'", &[]) {
    Err(SqlError::TypeMismatch { column, expected, got }) => {
        // column "age", expected Uint8, got "String"
    }
    _ => {}
}
}

A NULL parameter in a comparison is also reported this way: use IS NULL.

InvalidLiteral

Cause: A literal or parameter has the right kind but a value the column cannot hold. reason describes the problem.

Examples:

  • 300 for a Uint8 column
  • '2026-02-30' for a Date column
  • 'xyz' for a Blob column, which expects hexadecimal digits
  • A negative LIMIT parameter

MissingWhereClause

Cause: An UPDATE or DELETE statement has no WHERE clause.

The statement is rejected so that a forgotten condition cannot change or remove every row. To target every row on purpose, write a condition that is true for all of them.

ParameterCountMismatch

Cause: The number of values passed to execute differs from the number of ? placeholders in the statement.

TransactionAlreadyActive

Cause: BEGIN was sent together with a transaction id. A transaction cannot be opened inside another one.

Solution: Send BEGIN without an id. If the earlier transaction is finished, send COMMIT or ROLLBACK with its id first.

NoActiveTransaction

Cause: COMMIT or ROLLBACK was sent without a transaction id.

Solution: Pass the id returned by BEGIN.

Unsupported

Cause: The statement is valid SQL, but combines features that cannot run together. The message names the combination.

Examples:

  • DISTINCT or an aggregate function together with a JOIN
  • A column in the select list of an aggregate query that is not in GROUP BY
  • LIKE in HAVING
  • The same table twice in one statement

Runtime

Cause: The database rejected the operation while running it. The wrapped DbmsError is one of the errors described in the sections above.

#![allow(unused)]
fn main() {
match engine.execute(&ctx, None, "INSERT INTO users (id, name) VALUES (1, 'A')", &[]) {
    Err(SqlError::Runtime(DbmsError::Query(QueryError::PrimaryKeyConflict))) => {
        println!("a user with this id already exists");
    }
    Err(SqlError::Runtime(error)) => println!("database error: {error}"),
    _ => {}
}
}

COMMIT sent with the id of a transaction that is already closed fails with Runtime(DbmsError::Query(QueryError::TransactionNotFound)), and so does any statement sent with an id the database does not know.


Error Handling Examples

Basic error handling:

#![allow(unused)]
fn main() {
let result = database.insert::<User>(user);

match result {
    Ok(()) => println!("Insert successful"),
    Err(DbmsError::Query(QueryError::PrimaryKeyConflict)) => {
        println!("User already exists");
    }
    Err(DbmsError::Query(QueryError::UniqueConstraintViolation { field })) => {
        println!("Duplicate value on field: {}", field);
    }
    Err(DbmsError::Query(QueryError::BrokenForeignKeyReference)) => {
        println!("Referenced record doesn't exist");
    }
    Err(DbmsError::Validation(msg)) => {
        println!("Validation error: {}", msg);
    }
    Err(e) => {
        println!("Database error: {:?}", e);
    }
}
}

Helper function pattern:

#![allow(unused)]
fn main() {
fn handle_db_error(error: DbmsError) -> String {
    match error {
        DbmsError::Query(QueryError::PrimaryKeyConflict) => {
            "Record with this ID already exists".to_string()
        }
        DbmsError::Query(QueryError::UniqueConstraintViolation { field }) => {
            format!("Duplicate value on unique field: {}", field)
        }
        DbmsError::Query(QueryError::BrokenForeignKeyReference) => {
            "Referenced record not found".to_string()
        }
        DbmsError::Query(QueryError::ForeignKeyConstraintViolation) => {
            "Cannot delete: record has dependencies".to_string()
        }
        DbmsError::Validation(msg) => format!("Invalid data: {}", msg),
        _ => format!("Unexpected error: {:?}", error),
    }
}
}

For IC client-specific error handling (double-result pattern), see the IC Errors Reference.

Architecture


Overview

wasm-dbms is built as a layered architecture where each layer has specific responsibilities and builds upon the layer below. The core DBMS engine (layers 1 to 3) is runtime-agnostic and lives in the wasm-dbms-* crates. On top of it sits an application layer, which is not part of this repository: it is written by whoever embeds wasm-dbms, such as the Internet Computer adapter ic-dbms.

This design provides:

  • Separation of concerns: Each layer focuses on one aspect
  • Testability: Layers can be tested independently
  • Portability: The generic layer runs on any WASM runtime (Wasmtime, Wasmer, WasmEdge)
  • Flexibility: Internal implementations can change without affecting APIs

Layered Architecture

┌──────────────────────────────────────────────────────────────┐
│               Layer 4: Application Layer                      │
│  Final database interface, permissions, ACL, custom logic     │
│  (user-owned, NOT part of wasm-dbms)                          │
╞══════════════════════════════════════════════════════════════╡
│                     Layer 3: API Layer                        │
│  Database trait, request/response types                       │
│  (Query, Filter, InsertRequest, UpdateRequest, Record)        │
├──────────────────────────────────────────────────────────────┤
│                     Layer 2: DBMS Layer                       │
│  Tables, CRUD operations, transactions, foreign keys          │
│  (TableRegistry, TransactionManager, query execution)         │
├──────────────────────────────────────────────────────────────┤
│                    Layer 1: Memory Layer                      │
│  Memory management, encoding/decoding, page allocation        │
│  (MemoryProvider, MemoryManager, Encode trait)                │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
                    ┌──────────────────┐
                    │ Memory Provider  │
                    │ (file, host, or  │
                    │  heap memory)    │
                    └──────────────────┘

Layers 1 to 3 are the wasm-dbms engine. Layer 4 belongs to the application.

Layer 1: Memory Layer

Crate: wasm-dbms-memory

Responsibilities:

  • Manage memory allocation (64 KiB pages)
  • Encode/decode data to/from binary format
  • Track free space and handle fragmentation
  • Provide abstraction for testing (heap vs persistent memory)

Key components:

ComponentPurpose
MemoryProviderAbstract interface for raw memory I/O
MemoryAccessTrait for page-level read/write operations (implemented by MemoryManager, interceptable by DBMS layer)
MemoryManagerAllocates and manages pages, implements MemoryAccess
Encode traitBinary serialization for all stored types
PageLedgerTracks which pages belong to which table
FreeSegmentsLedgerTracks free space for reuse
IndexLedgerManages B+ tree indexes for a table
AutoincrementLedgerTracks autoincrement counters per column

Memory layout:

Page 0: Schema Registry (table → page mapping)
Page 1: Unclaimed Pages Ledger (released pages available for reuse)
Page 2+: Table data (Page Ledger, Free Segments, Index Ledger, Autoincrement Ledger, Records, B-tree nodes)

See Memory Documentation for detailed technical information.

Layer 2: DBMS Layer

Crate: wasm-dbms

Responsibilities:

  • Implement CRUD operations
  • Manage transactions with ACID properties
  • Enforce foreign key constraints
  • Handle sanitization and validation
  • Execute queries with filters

Key components:

ComponentPurpose
DbmsContext<M>Owns all DBMS state (memory, schema, transactions, journal)
WasmDbmsDatabaseSession-scoped DBMS operations
TableRegistryManages records for a single table
TransactionSessionHandles transaction lifecycle
TransactionOverlay for uncommitted changes
IndexOverlayTracks uncommitted index changes within a transaction
JournalWrite-ahead journal recording original bytes for rollback
JournaledWriterWraps MemoryManager + Journal, implements MemoryAccess to intercept writes
FilterAnalyzerExtracts index plans from query filters
IndexReaderUnified view over base index and transaction overlay
JoinEngineExecutes cross-table join queries

Transaction model:

┌──────────────────────────────────────────┐
│           Active Transactions             │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐   │
│  │  Tx 1   │  │  Tx 2   │  │  Tx 3   │   │
│  │ (overlay)│  │(overlay)│  │(overlay)│   │
│  └────┬────┘  └────┬────┘  └────┬────┘   │
│       │            │            │         │
│       └────────────┼────────────┘         │
│                    │                      │
│                    ▼                      │
│         ┌─────────────────┐              │
│         │  Committed Data │              │
│         │   (in memory)   │              │
│         └─────────────────┘              │
└──────────────────────────────────────────┘

Transactions use an overlay pattern:

  • Changes are written to an overlay (in-memory)
  • Reading checks overlay first, then committed data
  • Index changes are tracked in a separate IndexOverlay per table
  • IndexReader merges base index results with overlay additions/removals
  • Commit merges overlay to committed data and flushes index changes to B-trees
  • Rollback discards the overlay — on-disk B-trees remain untouched

Layer 3: API Layer

Crate: wasm-dbms-api

Responsibilities:

  • Define the Database trait, the entry point for all operations
  • Define request and response types
  • Define shared data types, errors, sanitizers, and validators

Key components:

ComponentPurpose
DatabaseTrait for CRUD, queries, transactions, and migrations
Request typesInsertRequest, UpdateRequest, Query, Filter
Response typesRecord, DbmsError, DbmsResult

Layer 4: Application Layer

Crate: none. This layer is written by the user of wasm-dbms.

The engine does not authenticate callers, check permissions, or decide how the database is exposed. The application layer wraps the Database trait and provides the interface that the final database presents to its clients.

Typical responsibilities:

  • Expose the database to the outside world (host functions, WIT interface, RPC endpoints, CLI, and so on)
  • Identify callers and enforce permissions or access control lists (ACL) before calling the engine
  • Decide who may use a transaction id. The engine addresses transactions by id only; an application layer that serves several identities keeps its own ledger from id to identity (see Embedding wasm-dbms)
  • Add business rules that go beyond sanitizers and validators
  • Provide the MemoryProvider for the target runtime and own the DbmsContext

For example, ic-dbms is an application layer for the Internet Computer. It exposes canister endpoints, checks access control, and ships client libraries. Read its documentation at https://ic.wasm-dbms.cc.


Crate Organization

wasm-dbms/
├── crates/
│   ├── wasm-dbms/                  # Generic WASM DBMS crates
│   │   ├── wasm-dbms-api/          # Shared types and traits
│   │   ├── wasm-dbms-memory/       # Memory abstraction and page management
│   │   ├── wasm-dbms/              # Core DBMS engine
│   │   ├── wasm-dbms-sql/          # SQL front-end (parser, planner, executor)
│   │   └── wasm-dbms-macros/       # Procedural macros (Encode, Table, CustomDataType, DatabaseSchema)
│   │
│   └── wasi-dbms/                  # WASI-specific crates
│       ├── wasi-dbms-memory/       # File-backed memory provider for WASI runtimes
│       └── wasi-dbms-key-value-memory/ # Draft2 key-value provider with checkpoints
│
└── .artifact/                      # Build outputs (.wasm)

Dependency Graph

wasm-dbms-macros <── wasm-dbms-api <── wasm-dbms-memory <── wasm-dbms <── wasm-dbms-sql

wasm-dbms-sql is optional: nothing depends on it, so programs that do not use SQL do not link it.

Application layers, such as ic-dbms, depend on these crates from separate repositories.

Generic Layer (wasm-dbms)

wasm-dbms-api

Purpose: Runtime-agnostic shared types and traits

Contents:

  • Data types (Uint32, Text, DateTime, etc.)
  • Value enum for runtime values
  • Filter, Query, and Join types
  • Database trait
  • Sanitizer and Validator traits
  • CustomDataType trait and CustomValue
  • Error types (DbmsError, DbmsResult)

Dependencies: Minimal (serde, thiserror).

wasm-dbms-memory

Purpose: Memory abstraction and page management

Contents:

  • MemoryProvider trait
  • HeapMemoryProvider (testing)
  • MemoryManager (page-level operations)
  • SchemaRegistry (table-to-page mapping)
  • TableRegistry (record-level operations)

wasm-dbms

Purpose: Core DBMS engine (runtime-agnostic)

Contents:

  • DbmsContext<M> (owns all mutable state)
  • WasmDbmsDatabase<'ctx, M> (session-scoped operations)
  • Transaction management (overlay pattern)
  • Foreign key integrity checks
  • JOIN execution engine
  • DatabaseSchema trait for dynamic dispatch

wasm-dbms-sql

Purpose: SQL front-end for the DBMS engine

Contents:

  • Lexer and recursive-descent parser for the SQL dialect
  • Syntax tree types (wasm_dbms_sql::ast)
  • Planner that resolves names against the schema, converts literals and parameters to column types, and builds Query and Filter values
  • SqlEngine, which runs statements through WasmDbmsDatabase and DatabaseSchema, outside a transaction or inside the one whose id is passed to execute

The result and error types (SqlResult, SqlError) live in wasm-dbms-api behind its sql feature, so that adapters can expose SQL without depending on the engine.

wasm-dbms-macros

Purpose: Generic procedural macros

Macros:

  • #[derive(Encode)] - Binary serialization
  • #[derive(Table)] - Table schema and related types
  • #[derive(CustomDataType)] - Custom data type bridge
  • #[derive(DatabaseSchema)] - Generates DatabaseSchema<M> trait implementation for schema dispatch

Data Flow

Insert Operation

1. Caller invokes Database::insert::<User>(request)
              │
2. Database session receives the request
              │
3. DBMS layer:
   a. Apply sanitizers to values
   b. Apply validators to values
   c. Check primary key uniqueness
   d. Validate foreign key references
   e. If tx_id: write to transaction overlay
      (index overlay tracks added keys)
      Else: write directly
              │
4. Memory layer:
   a. Encode record to bytes
   b. Find space (free segment or new page)
   c. Write to memory
   d. Update all indexes with the new
      key → RecordAddress mapping
              │
5. Return DbmsResult<()>

Select Operation

1. Caller invokes Database::select::<User>(query)
              │
2. Database session receives the query
              │
3. DBMS layer:
   a. Parse filters
   b. Analyze filter for index plan
      (equality, range, or IN on indexed column)
   c. If index plan found:
      - Use IndexReader to get RecordAddresses
      - Load only matching records
      - Apply remaining filter as residual
   d. If no index plan (fallback):
      - Full table scan across all pages
   e. If tx_id: merge with overlay
   f. Apply ordering
   g. Apply limit/offset
   h. Select requested columns
   i. Handle eager loading
              │
4. Memory layer:
   a. Read pages
   b. Decode records
              │
5. Return DbmsResult<Vec<Record>>

Select with Join

1. Caller invokes Database::select_join(table, query_with_joins)
              │
2. Database session checks query.has_joins()
              │ (true)
3. JoinEngine:
   a. Read all rows from FROM table
   b. For each JOIN clause:
      - Read rows from joined table (left keys pushed down when the join column
        is indexed)
      - Resolve column references
      - Execute hash join over row indices
   c. Apply filter on combined rows
   d. Apply ordering
   e. Apply offset/limit
   f. Build the column list once and materialize the rows
              │
4. Return DbmsResult<JoinResultSet>

See Join Engine for implementation details.

Transaction Flow

DbmsContext::begin_transaction():
  1. Generate transaction ID
  2. Create empty overlay
  3. Return transaction ID

Operation within a transaction (WasmDbmsDatabase::from_transaction):
  1. Read from: overlay first, then committed
  2. Write to: overlay only

commit():
  1. For each change in overlay:
     - Write to committed data
  2. Delete overlay
  3. Transaction ID becomes invalid

rollback():
  1. Delete overlay (discard all changes)
  2. Transaction ID becomes invalid

Extension Points

wasm-dbms provides several extension points for customization:

Custom Sanitizers

Implement the Sanitize trait:

#![allow(unused)]
fn main() {
pub trait Sanitize {
    fn sanitize(&self, value: Value) -> DbmsResult<Value>;
}
}

Custom Validators

Implement the Validate trait:

#![allow(unused)]
fn main() {
pub trait Validate {
    fn validate(&self, value: &Value) -> DbmsResult<()>;
}
}

Custom Data Types

Define custom data types with the CustomDataType derive macro:

#![allow(unused)]
fn main() {
#[derive(Encode, CustomDataType, Clone, Debug, PartialEq, Eq)]
#[type_tag = "status"]
pub enum Status {
    Active,
    Inactive,
}
}

Memory Provider

Implement MemoryProvider for custom memory backends:

#![allow(unused)]
fn main() {
pub trait MemoryProvider {
    const PAGE_SIZE: u64;
    fn size(&self) -> u64;
    fn pages(&self) -> u64;
    fn grow(&mut self, new_pages: u64) -> MemoryResult<u64>;
    fn read(&mut self, offset: u64, buf: &mut [u8]) -> MemoryResult<()>;
    fn write(&mut self, offset: u64, buf: &[u8]) -> MemoryResult<()>;
    fn flush(&mut self) -> MemoryResult<()>;
}
}

Built-in providers:

  • WasiMemoryProvider - Uses a single flat file (WASI production)
  • WasiKeyValueMemoryProvider - Uses a draft2 key-value bucket and explicit checkpoints
  • HeapMemoryProvider - Uses heap memory (testing)

Other runtimes ship their own provider. For the Internet Computer, see ic-dbms.

Memory Management


Overview

This document provides the technical details of memory management in wasm-dbms, also known as Layer 0 (the Memory Layer). Understanding this layer is useful for:

  • Performance optimization
  • Debugging memory issues
  • Contributing to wasm-dbms
  • Understanding storage costs

How WASM Memory Works

A WASM module addresses memory in 64 KiB (65,536 bytes) pages. wasm-dbms uses the same page size for its own storage, regardless of where the bytes live. Key characteristics:

  • Page-based: Memory is divided into 64 KiB pages
  • Growable: Storage starts small and allocates additional pages on demand
  • Provider-defined persistence: Whether the bytes survive a restart depends on the MemoryProvider in use (a file for WASI, the heap for tests)

wasm-dbms never assumes a specific host. It reads and writes through the MemoryProvider trait, so the host decides where the data is stored. Runtime-specific providers, such as the one for the Internet Computer in ic-dbms, live in their own projects.


Memory Model

┌──────────────────────────────────────────────────┐
│ Page 0: Schema Registry (65 KiB)                 │
│   - Table name hashes → table registry pages     │
├──────────────────────────────────────────────────┤
│ Page 1: Unclaimed Pages Ledger (65 KiB)          │
│   - Stack of pages released by destructive ops   │
├──────────────────────────────────────────────────┤
│ Page 2: Table "users" Schema Snapshot            │
├──────────────────────────────────────────────────┤
│ Page 3: Table "users" Page Ledger                │
├──────────────────────────────────────────────────┤
│ Page 4: Table "users" Free Segments Ledger       │
├──────────────────────────────────────────────────┤
│ Page 5: Table "users" Index Ledger               │
├──────────────────────────────────────────────────┤
│ Page 6: Table "users" Autoincrement Ledger (*)   │
├──────────────────────────────────────────────────┤
│ Page 7: Table "posts" Schema Snapshot            │
├──────────────────────────────────────────────────┤
│ Page 8: Table "posts" Page Ledger                │
├──────────────────────────────────────────────────┤
│ Page 9: Table "posts" Free Segments Ledger       │
├──────────────────────────────────────────────────┤
│ Page 10: Table "posts" Index Ledger              │
├──────────────────────────────────────────────────┤
│ Page 11: Table "users" Records - Page 1          │
├──────────────────────────────────────────────────┤
│ Page 12: Table "users" Records - Page 2          │
├──────────────────────────────────────────────────┤
│ Page 13: B-Tree Node (index on users.id)         │
├──────────────────────────────────────────────────┤
│ Page 14: B-Tree Node (index on users.email)      │
├──────────────────────────────────────────────────┤
│ Page 15: Table "posts" Records - Page 1          │
├──────────────────────────────────────────────────┤
│ ...                                              │
└──────────────────────────────────────────────────┘
(*) Only allocated for tables with #[autoincrement] columns

Layout characteristics:

  • Reserved pages (0-1) are allocated at initialization
  • Each table gets a Schema Snapshot, Page Ledger, Free Segments Ledger, and Index Ledger
  • Tables with #[autoincrement] columns also get an Autoincrement Ledger page
  • Record pages and B-tree node pages are allocated on demand
  • Pages can be interleaved between tables
  • Pages released by destructive ops (e.g. DropTable) are returned to the Unclaimed Pages Ledger and reused by subsequent claim_page calls before the high-water mark is bumped

Memory Provider

The MemoryProvider trait abstracts memory access:

#![allow(unused)]
fn main() {
pub trait MemoryProvider {
    /// Size of a memory page in bytes (64 KiB)
    const PAGE_SIZE: u64;

    /// Current memory size in bytes
    fn size(&self) -> u64;

    /// Number of allocated pages
    fn pages(&self) -> u64;

    /// Grow memory by new_pages
    /// Returns previous size in bytes on success
    fn grow(&mut self, new_pages: u64) -> MemoryResult<u64>;

    /// Read bytes from memory at offset
    fn read(&mut self, offset: u64, buf: &mut [u8]) -> MemoryResult<()>;

    /// Write bytes to memory at offset
    fn write(&mut self, offset: u64, buf: &[u8]) -> MemoryResult<()>;

    /// Publish provider-specific durable state. The default is a no-op.
    fn flush(&mut self) -> MemoryResult<()>;
}
}

Implementations:

ImplementationUse Case
WasiMemoryProviderWASI production (file-backed, single flat file)
WasiKeyValueMemoryProviderWASI key-value host with explicit checkpoints
HeapMemoryProviderTesting (uses Vec<u8>)
#![allow(unused)]
fn main() {
// Testing: Uses heap memory
pub struct HeapMemoryProvider {
    memory: Vec<u8>,
}
}

Memory Manager and MemoryAccess

The MemoryManager builds on MemoryProvider to handle page allocation. Its page-level read/write operations are exposed through the MemoryAccess trait, which allows the DBMS layer to substitute a journaled writer for atomic transactions (see Atomicity).

#![allow(unused)]
fn main() {
/// Abstracts page-level read/write operations.
///
/// `MemoryManager` implements this trait directly. The DBMS layer provides
/// `JournaledWriter`, which wraps a `MemoryManager` and records original
/// bytes before each write for rollback support.
pub trait MemoryAccess {
    fn page_size(&self) -> u64;
    /// Hand out a page — pops from the unclaimed-pages ledger if one is
    /// available, otherwise grows the underlying provider.
    fn claim_page(&mut self) -> MemoryResult<Page>;
    /// Return a page to the unclaimed-pages ledger after zeroing it.
    fn unclaim_page(&mut self, page: Page) -> MemoryResult<()>;
    /// Grow the provider by exactly one zero-initialized page (primitive).
    fn grow_one_page(&mut self) -> MemoryResult<Page>;
    /// Zero an entire allocated page (primitive).
    fn zero_page(&mut self, page: Page) -> MemoryResult<()>;
    fn read_at<D: Encode>(&mut self, page: Page, offset: PageOffset) -> MemoryResult<D>;
    fn write_at<E: Encode>(&mut self, page: Page, offset: PageOffset, data: &E)
    -> MemoryResult<()>;
    fn zero<E: Encode>(&mut self, page: Page, offset: PageOffset, data: &E) -> MemoryResult<()>;
    fn read_at_raw(
        &mut self,
        page: Page,
        offset: PageOffset,
        buf: &mut [u8],
    ) -> MemoryResult<usize>;
}
}

claim_page and unclaim_page ship as default trait methods built on top of the four primitives — every implementor automatically inherits the unclaimed-pages-aware allocation strategy. JournaledWriter overrides only grow_one_page (intentionally not journaled, since extending the high-water mark cannot be replayed in reverse) and zero_page (records the full pre-zero page contents so a rollback restores them).

#![allow(unused)]
fn main() {
pub struct MemoryManager<P: MemoryProvider> {
    provider: P,
}

// All mutable state is consolidated in a single DbmsContext:
let ctx = DbmsContext::new(HeapMemoryProvider::default());

impl<P: MemoryProvider> MemoryManager<P> {
    /// Initialize and allocate reserved pages
    fn init(provider: P) -> Self;

    /// Schema registry page (always 0)
    pub const fn schema_page(&self) -> Page;
}

// MemoryAccess is implemented for MemoryManager<P>,
// delegating directly to the underlying MemoryProvider.
impl<P: MemoryProvider> MemoryAccess for MemoryManager<P> {
    /* ... */
}
}

All table-registry and ledger functions are generic over impl MemoryAccess rather than taking &[mut] MemoryManager directly. This makes it possible to intercept writes at the DBMS layer without modifying any memory-crate code.


Unclaimed Pages Ledger

The unclaimed-pages ledger lives on reserved page 1 (UNCLAIMED_PAGES_PAGE). It is a LIFO stack of [Page] numbers that destructive operations have released. claim_page consults this stack before bumping the high-water mark; unclaim_page zeroes the page and pushes it onto the stack.

Serialization format:

Offset   Size   Field
0        4      Number of unclaimed pages (u32, little-endian)
4+       var    Sequence of page numbers (u32, little-endian) — newest last

The single reserved page can hold up to UNCLAIMED_PAGES_CAPACITY = 16382 entries (the encoded ledger size must fit in MSize = u16). Pushing beyond capacity returns MemoryError::UnclaimedPagesFull. A future extension may chain additional pages once a real workload exhausts the single-page budget; for now the v1 limit is enough to absorb typical drop-and-recreate cycles.

Behavior:

  • claim_page pops the most recently unclaimed page (LIFO ordering keeps hot pages cache-warm). When the ledger is empty it grows the provider by exactly one zero-initialized page.
  • unclaim_page zeroes the page in full before pushing — released pages never leak residual record bytes. The zero is journaled when invoked through JournaledWriter, so a rolled-back transaction restores the page contents and the ledger update.
  • The default MemoryAccess impls of claim_page and unclaim_page drive the ledger entirely through the trait’s read/write methods, so every interceptor (journal, future overlays) automatically participates.
  • MigrationOp::DropTable walks every page owned by the dropped table — record pages, page-ledger / free-segments / index-ledger pages, every B-tree node, schema-snapshot and (optional) autoincrement pages — and hands each one to unclaim_page before clearing the table from the schema registry.

Rollback semantics:

Inside an atomic block:

  • A successful unclaim_page whose surrounding transaction rolls back is fully reversed: the page contents reappear and the ledger does not contain the page.
  • A claim_page that hits the ledger pops a page; on rollback the ledger is restored and the page is “back” in the unclaimed pool. Any data the caller wrote into that page is also reverted by the journal.
  • A claim_page that grows the provider returns a page whose existence is not journaled; on rollback the page stays grown but unreferenced (it leaks until the next process lifetime). This matches the previous behavior of allocate_page and is unchanged by this design.

Encode Trait

All data stored in memory implements the Encode trait:

#![allow(unused)]
fn main() {
pub trait Encode {
    /// Size characteristic: Fixed or Dynamic
    const SIZE: DataSize;

    /// Memory alignment in bytes
    /// - For Fixed: must equal size
    /// - For Dynamic: minimum 8, default 32
    const ALIGNMENT: PageOffset;

    /// Encode to bytes
    fn encode(&'_ self) -> Cow<'_, [u8]>;

    /// Decode from bytes
    fn decode(data: Cow<[u8]>) -> MemoryResult<Self>
    where
        Self: Sized;

    /// Size of encoded data
    fn size(&self) -> MSize;
}

pub enum DataSize {
    /// Fixed size in bytes (e.g., integers)
    Fixed(MSize),
    /// Variable size (e.g., strings, blobs)
    Dynamic,
}
}

Examples:

TypeSIZEALIGNMENT
Uint32Fixed(4)4
Int64Fixed(8)8
TextDynamic32 (default)
BlobDynamic32 (default)
User-defined recordDynamicConfigurable (default 32)

Schema Registry

The Schema Registry maps tables to their storage pages:

#![allow(unused)]
fn main() {
/// Information about a table's storage pages
pub struct TableRegistryPage {
    pub schema_snapshot_page: Page, // Schema Snapshot Ledger location
    pub pages_list_page: Page,      // Page Ledger location
    pub free_segments_page: Page,   // Free Segments Ledger location
    pub index_registry_page: Page,  // Index Ledger location
    pub autoincrement_registry_page: Option<Page>, // Autoincrement Ledger (if needed)
}

/// Maps table fingerprints to storage locations
pub struct SchemaRegistry {
    tables: HashMap<TableFingerprint, TableRegistryPage>,
}
}

The autoincrement_registry_page is only allocated when a table has at least one column with the #[autoincrement] attribute. For tables without autoincrement columns, this field is None, avoiding unnecessary page allocation.

Table Fingerprint:

  • Hash of TableSchema::table_name() — stable across rebuilds and schema evolution
  • Used as the key into the schema registry’s HashMap
  • Enables multiple tables in one memory instance

Name collision detection:

SchemaRegistry::register_table is collision-aware. When the fingerprint slot is already occupied, the registry loads the persisted Schema Snapshot from that table’s schema_snapshot_page and compares its name against the candidate’s TableSchema::table_name():

  • Same name: the entry is the same logical table — return the existing pages, no allocation
  • Different name: two distinct names hashed to the same value — return MemoryError::NameCollision { candidate, existing } without allocating any page

Table Registry

Each table has a TableRegistry managing its records, plus an optional AutoincrementLedger for tables with autoincrement columns:

#![allow(unused)]
fn main() {
pub struct TableRegistry {
    schema_snapshot_page: Page,
    page_ledger: PageLedger,
    free_segments_ledger: FreeSegmentsLedger,
    index_ledger: IndexLedger,
    auto_increment_ledger: Option<AutoincrementLedger>,
}
}

TableRegistry::load runs before every CRUD operation, so it reads only what those operations need:

  • The page, free-segments and index ledgers are read with read_sized, which reads a short prefix of the page, works out the encoded length from the entry counts and then copies exactly those bytes instead of the whole page.
  • The schema snapshot is not read at all. Only migrations use it, and they load it on demand through TableRegistry::schema_snapshot_ledger(mm).

Schema Snapshot Ledger

The SchemaSnapshotLedger persists a single TableSchemaSnapshot on the table’s schema_snapshot_page. The snapshot is the frozen, comparable view of the table’s compile-time schema — name, primary key, alignment, columns, and indexes — used for drift detection and migration planning.

#![allow(unused)]
fn main() {
pub struct SchemaSnapshotLedger {
    snapshot: TableSchemaSnapshot, // cached copy of the on-disk snapshot
}

impl SchemaSnapshotLedger {
    pub fn init<Schema: TableSchema>(page: Page, mm: &mut impl MemoryAccess) -> MemoryResult<()>;
    pub fn load(page: Page, mm: &mut impl MemoryAccess) -> MemoryResult<Self>;
    pub fn write(
        &mut self,
        page: Page,
        snapshot: TableSchemaSnapshot,
        mm: &mut impl MemoryAccess,
    ) -> MemoryResult<()>;
    pub fn get(&self) -> &TableSchemaSnapshot;
}
}

Behavior:

  • init is called exactly once per table by SchemaRegistry::register_table, capturing the snapshot from TableSchema::schema_snapshot() and writing it to the dedicated page
  • load decodes the persisted snapshot and caches it in memory
  • write replaces the persisted snapshot (used after a successful migration) and updates the cache; on write error the cache is left untouched
  • get returns the cached snapshot — no I/O on the hot path

Serialization format (TableSchemaSnapshot):

Offset   Size     Field
0        1        Snapshot format version (u8, current = 0x01)
1        1        Table name length (u8)
2+       N1       UTF-8 table name
+        1        Primary key column name length (u8)
+        N2       UTF-8 primary key column name
+        4        Record alignment (u32, little-endian)
+        2        Column count (u16, little-endian)
+        var      For each column:
                    - 2 bytes: encoded column size (u16, little-endian)
                    - var bytes: encoded `ColumnSnapshot`
+        2        Index count (u16, little-endian)
+        var      For each index:
                    - 2 bytes: encoded index size (u16, little-endian)
                    - var bytes: encoded `IndexSnapshot`

ColumnSnapshot encodes name, data-type tag (with optional payload for Custom), nullable / auto-increment / unique / primary-key flags, optional foreign key, and optional default value. IndexSnapshot encodes the covered column names plus the unique flag.

Every one-byte length or count in the snapshot is limited to 255 and every two-byte one to 65 535, and the whole encoded snapshot must fit in one MSize. TableSchemaSnapshot::validate_encoding checks these limits; registering a table and writing a snapshot reject metadata that exceeds them with MemoryError::ConstraintViolation before anything is written.

Stability rules:

  • DataTypeSnapshot discriminants are frozen — never reordered, never reused
  • New fields append at the tail and bump the container version; old readers stop at the previous length prefix
  • Removed fields leave their slot reserved; later fields do not shift

See Schema Reference for the full per-field layout.

Page Ledger

Tracks which pages contain records for this table:

#![allow(unused)]
fn main() {
pub struct PageLedger {
    ledger_page: Page, // Where this ledger is stored
    pages: PageTable,  // List of data pages with free space info
}

impl PageLedger {
    /// Load from memory
    pub fn load(page: Page) -> MemoryResult<Self>;

    /// Get page for writing a record
    /// Returns existing page with space or allocates new
    pub fn get_page_for_record<R: Encode>(&mut self, record: &R) -> MemoryResult<Page>;

    /// Commit allocation (update free space tracking)
    pub fn commit<R: Encode>(&mut self, page: Page, record: &R) -> MemoryResult<()>;
}
}

Free Segments Ledger

Tracks free space from deleted/moved records:

#![allow(unused)]
fn main() {
pub struct FreeSegmentsLedger {
    free_segments_page: Page,
    tables: PagesTable, // Pages containing FreeSegmentsTables
}

pub struct FreeSegment {
    pub page: Page,
    pub offset: PageOffset,
    pub size: MSize,
}

impl FreeSegmentsLedger {
    /// Insert a free segment (when record is deleted)
    pub fn insert_free_segment<E: Encode>(
        &mut self,
        page: Page,
        offset: PageOffset,
        record: &E,
    ) -> MemoryResult<()>;

    /// Find reusable space for a record
    pub fn find_reusable_segment<E: Encode>(
        &self,
        record: &E,
    ) -> MemoryResult<Option<FreeSegmentTicket>>;

    /// Commit reused space
    pub fn commit_reused_space<E: Encode>(
        &mut self,
        record: &E,
        segment: FreeSegmentTicket,
    ) -> MemoryResult<()>;
}
}

Space reuse logic:

  1. When a record is deleted, its space is added to free segments
  2. When inserting, check for suitable free segment first
  3. If found, reuse the space; remaining space becomes new free segment
  4. Adjacent free segments are merged to reduce fragmentation

Autoincrement Ledger

Tables with #[autoincrement] columns have a dedicated page storing the current counter value for each autoincrement column. The AutoincrementLedger manages these counters:

#![allow(unused)]
fn main() {
pub struct AutoincrementLedger {
    page: Page,
    registry: AutoincrementRegistry, // column name → current Value
}
}

Serialization format:

Offset   Size    Field
0        1       Number of entries (u8, at most 255)
1+       var     For each entry:
                 - 1 byte: column name length (u8, at most 255)
                 - N bytes: UTF-8 column name
                 - var bytes: encoded Value (type-tagged)

Behavior:

  • Initialized with zero values matching each column’s integer type when a table is registered
  • next() increments the counter by one and persists the updated value to memory
  • Uses checked_add — returns MemoryError::AutoincrementOverflow when a column reaches its type’s maximum value, preventing duplicate key generation
  • Each column’s counter is independent; advancing one does not affect others
  • State survives across load()/save() cycles (persisted to the dedicated page)

Supported types:

TypeRange
Int8-128 to 127
Int16-32,768 to 32,767
Int32-2,147,483,648 to 2,147,483,647
Int64-9.2 × 10¹⁸ to 9.2 × 10¹⁸
Uint80 to 255
Uint160 to 65,535
Uint320 to 4,294,967,295
Uint640 to 18.4 × 10¹⁸

Record Storage

Record Encoding

Records are wrapped in RawRecord with a length header:

┌─────────────────────────────────────────┐
│  2 bytes: Data length (little-endian)   │
├─────────────────────────────────────────┤
│  N bytes: Encoded data                  │
├─────────────────────────────────────────┤
│  Padding to alignment boundary          │
└─────────────────────────────────────────┘

Dynamic size example (alignment=32, data=24 bytes):

Bytes 0-1:   Data length (24)
Bytes 2-25:  Data (24 bytes)
Bytes 26-31: Padding (6 bytes)
Total: 32 bytes (aligned)

Fixed size example (size=14 bytes):

Bytes 0-1:   Data length (14)
Bytes 2-15:  Data (14 bytes)
Total: 16 bytes (no padding for fixed)

Record Alignment

Alignment ensures efficient memory access:

#![allow(unused)]
fn main() {
impl<E: Encode> Alignment for E {
    fn alignment() -> usize {
        match E::SIZE {
            DataSize::Fixed(size) => size as usize,
            DataSize::Dynamic => E::ALIGNMENT as usize,
        }
    }
}

fn align_up<E: Encode>(size: usize) -> usize {
    let align = E::alignment();
    (size + align - 1) / align * align
}
}

Configuring alignment:

#![allow(unused)]
fn main() {
#[derive(Table, ...)]
#[table = "large_records"]
#[alignment = 64] // Custom alignment for this table
pub struct LargeRecord {
    // ...
}
}

Table Reader

Reading records from a table:

#![allow(unused)]
fn main() {
impl<E: Encode> TableRegistry<E> {
    pub fn read_all(&self) -> MemoryResult<Vec<E>> {
        let mut records = Vec::new();

        for page in self.page_ledger.pages() {
            let mut offset = 0;

            while offset < PAGE_SIZE {
                // Read length header
                let len = read_u16_le(page, offset);

                if len == 0 {
                    // Skip empty slot
                    offset += E::alignment();
                    continue;
                }

                // Read and decode record
                let data = read_bytes(page, offset + 2, len);
                let record = E::decode(data)?;
                records.push(record);

                // Move to next aligned position
                offset += align_up::<E>(len + 2);
            }
        }

        Ok(records)
    }
}
}

Read process:

  1. Read 2 bytes at offset for data length
  2. If length is 0, skip to next aligned position
  3. Read length bytes of data
  4. Decode data into record
  5. Move to next aligned position
  6. Repeat until end of page

Index Registry

Each table has an IndexLedger that maps index definitions (column sets) to B-tree root pages. Indexes are always B+ trees where each node occupies exactly one memory page (64 KiB).

Every table automatically gets an index on its primary key. Additional indexes can be declared with the #[index] attribute (see Schema Reference).

Index Ledger

The IndexLedger is stored in a single page per table and maps column sets to B-tree root pages:

#![allow(unused)]
fn main() {
pub struct IndexLedger {
    ledger_page: Page,
    tables: HashMap<Vec<String>, Page>, // column names → root page
}
}

Serialization format:

Offset   Size    Field
0-7      8       Number of indexes (u64)
8+       var     For each index:
                 - 8 bytes: column count (u64)
                 - For each column name:
                   - 1 byte: name length (u8, at most 255)
                   - N bytes: UTF-8 column name
                 - 4 bytes: root page (u32)

Index and autoincrement column names longer than 255 bytes, and tables with more than 255 autoincrement columns, are rejected with MemoryError::ConstraintViolation when the table is registered, before any page is claimed.

When a table is registered via SchemaRegistry::register_table(), the index ledger is initialized by allocating one root page per index definition. The ledger supports insert, delete, update, exact-match search, and range scan operations — all delegated to the underlying B-tree for the appropriate column set.

B-Tree Structure

Indexes use a B+ tree where values (record pointers) are stored only in leaf nodes. Internal nodes contain separator keys that guide traversal. Each node is a single page.

#![allow(unused)]
fn main() {
struct RecordAddress {
    page: Page,         // 4 bytes, u32
    offset: PageOffset, // 2 bytes, u16
}
}

RecordAddress is the pointer stored in leaf entries, pointing to the exact location of the record in the table’s data pages. It is 6 bytes when serialized.

Key characteristics:

  • Variable-size keys: Entries are packed as many as fit in a 64 KiB page
  • Non-unique: The same key can map to multiple RecordAddress values
  • Linked leaves: Leaf nodes form a doubly-linked list for range scans
  • Node type tag: Byte 0 distinguishes internal (0x00) from leaf (0x01) nodes

Internal Node Layout

┌──────────────────────────────────────────────────────┐
│ Byte 0:     Node type (0x00 = INTERNAL)              │
│ Bytes 1-4:  Parent page (u32, u32::MAX if root)      │
│ Bytes 5-6:  Entry count (u16)                        │
│ Bytes 7-10: Rightmost child page (u32)               │
├──────────────────────────────────────────────────────┤
│ Entry 0:                                             │
│   Bytes 0-1: Key size (u16)                          │
│   Bytes 2+:  Key data (variable)                     │
│   Next 4:    Child page (u32)                        │
├──────────────────────────────────────────────────────┤
│ Entry 1: ...                                         │
├──────────────────────────────────────────────────────┤
│ ...                                                  │
└──────────────────────────────────────────────────────┘

Header size: 11 bytes. Entries are sorted by key. A search for key K routes to the child page of the first entry whose key is >= K, or to rightmost_child if K is greater than all entries.

Leaf Node Layout

┌──────────────────────────────────────────────────────┐
│ Byte 0:      Node type (0x01 = LEAF)                 │
│ Bytes 1-4:   Parent page (u32, u32::MAX if root)     │
│ Bytes 5-6:   Entry count (u16)                       │
│ Bytes 7-10:  Previous leaf page (u32, u32::MAX=none) │
│ Bytes 11-14: Next leaf page (u32, u32::MAX=none)     │
├──────────────────────────────────────────────────────┤
│ Entry 0:                                             │
│   Bytes 0-1: Key size (u16)                          │
│   Bytes 2+:  Key data (variable)                     │
│   Next 6:    RecordAddress (4-byte page + 2-byte     │
│              offset)                                 │
├──────────────────────────────────────────────────────┤
│ Entry 1: ...                                         │
├──────────────────────────────────────────────────────┤
│ ...                                                  │
└──────────────────────────────────────────────────────┘

Header size: 15 bytes. Entries are sorted by (key, record address). The prev_leaf / next_leaf pointers form a doubly-linked list across all leaves, enabling efficient forward and backward range scans.

Index Maintenance

Indexes are updated eagerly on every write operation:

  • INSERT: After writing the record and obtaining its RecordAddress, the key is inserted into every index defined on the table.
  • DELETE: After removing the record, the key-pointer pair is removed from every index.
  • UPDATE: If indexed columns changed, those indexes are updated (delete old key + insert new key). If the record moved (size change), all indexes are updated with the new RecordAddress.

When a node overflows during insertion, it splits at the entry boundary that best balances the encoded byte size of the two halves while keeping each half within one page. Splitting by byte size instead of entry count lets keys of very different lengths share a tree. For a leaf, the first key of the new right sibling is promoted to the parent internal node. If the parent also overflows, the split propagates upward. When the root splits, a new root is created and the tree height increases by one.

When a leaf becomes empty after deletion, it stays in the leaf chain and its parent keeps routing keys to it, so a later insert that lands in it is reachable by both exact-match lookups and range scans. Empty leaves are not reclaimed; lookups and range scans step over them.

Index Tree Walker

Range scans use an IndexTreeWalker that iterates through leaf entries across linked leaf pages:

#![allow(unused)]
fn main() {
pub struct IndexTreeWalker<K: Encode + Ord> {
    entries: Vec<LeafEntry<K>>, // Current leaf's entries
    cursor: usize,              // Position within current leaf
    next_leaf: Option<Page>,    // Next leaf page for continuation
    end_key: Option<K>,         // Optional upper bound (exclusive)
}
}

The walker starts at the first leaf entry >= start_key and advances through the linked-leaf chain until it reaches an entry >= end_key (or exhausts all leaves). next returns record addresses; next_entry also returns each key, which prefix scans and covering reads need. Both skip emptied leaves before reporting the end of the scan. Prefix scans seek to the prefix (a shorter key sorts before every longer key sharing it) and stop as soon as the prefix changes, without reading the rest of the index.

Join Engine


Overview

The join engine executes cross-table join queries, combining rows from two or more tables based on column equality conditions. It supports four join types — INNER, LEFT, RIGHT, and FULL — and integrates with the existing query pipeline for filtering, ordering, pagination, and column selection.

The implementation lives in crates/wasm-dbms/wasm-dbms/src/join.rs.


Architecture

The engine is implemented as a generic struct:

#![allow(unused)]
fn main() {
pub struct JoinEngine<'a, Schema: ?Sized, M>
where
    Schema: DatabaseSchema<M>,
    M: MemoryProvider,
{
    schema: &'a Schema,
}
}

Key design decisions:

  • Schema: ?Sized — The ?Sized bound allows the engine to work with Box<dyn DatabaseSchema>, which is how the API layer passes the schema at runtime.
  • Borrows DatabaseSchema — The engine borrows the schema to read rows via schema.select(dbms, table, query) and compile-time column definitions via schema.table_columns(table).
  • Stateless — The engine holds no mutable state; it takes a Query and returns results in a single call.

The DatabaseSchema trait provides the select method that the engine uses to read all rows from each table involved in the join.


Processing Pipeline

The join() method processes a query through these steps:

┌──────────────────────────┐
│ 1. Load table schemas    │
│    Validate filter       │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 2. Read FROM table rows  │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 3. For each JOIN clause:  │◄──── left-to-right
│    Read right table rows  │
│    Hash join              │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 4. Apply filter           │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 5. Apply ordering         │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 6. Apply offset           │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 7. Apply limit            │
└────────────┬─────────────┘
             │
┌────────────▼─────────────┐
│ 8. Materialize result set │
└──────────────────────────┘
  1. Load schemas and validate: Compile-time column definitions are loaded for every table. The filter is validated against those definitions so ambiguous references, out-of-scope tables, and invalid typed operands fail even when the join produces no rows.
  2. Read FROM table: All rows from the primary table are loaded using an unfiltered Query::builder().all().build().
  3. Process JOINs: Each Join clause is processed left-to-right. For each clause, the right table rows are loaded (see Hash Join Algorithm for when the left keys are pushed down) and the hash join is executed against the accumulated result.
  4. Filter: The validated filter is applied to the combined rows using filter.matches_joined_row_ref(), which borrows the loaded rows and supports qualified table.column references.
  5. Order: Order-by clauses are applied in reverse (stable sort), so the primary sort key ends up correctly ordered.
  6. Offset: Rows are skipped according to the offset value.
  7. Limit: The result is truncated to the limit.
  8. Materialize: Each joined row, held until now as one row index per table, is turned into a Vec<Value> of the selected columns. The JoinColumnDef list is built once per query and returned next to the rows as a JoinResultSet.

Hash Join Algorithm

All four join types are handled by a single hash_join method using two boolean flags:

Join Typekeep_unmatched_leftkeep_unmatched_right
INNERfalsefalse
LEFTtruefalse
RIGHTfalsetrue
FULLtruetrue

Every table taking part in the join is loaded once into a JoinSide (its rows, its compile-time columns, and a NULL row of the same shape). An intermediate joined row is a Vec<Option<usize>> with one entry per side: the index of that side’s row, or None when the side is NULL-padded.

The algorithm, per join clause:

  1. Build a HashMap<&Value, Vec<usize>> over the right rows, keyed by the join column. NULL keys are skipped.
  2. For each accumulated left row, look up its join key in the map. Every hit emits a combined row (left indices plus the right index) and marks the right row as matched. Hits are emitted in right-row order, so output order equals the former nested loop.
  3. If keep_unmatched_left is true and the key had no hit, emit the left row with None for the right side.
  4. After all left rows: if keep_unmatched_right is true, emit each unmatched right row with None for every left side.

Cost is O(n + m + k) per join clause for n left rows, m right rows and k output rows, instead of O(n*m).

Loading the right side

Right and full joins need every right row. For inner and left joins the distinct left keys are pushed down as an IN filter only when the right join column leads an index of the right table (DatabaseSchema::table_indexes). The planner answers that filter with index lookups. On an unindexed column an IN filter costs a list scan per row, so the table is scanned in full and the hash join does the matching.


NULL Padding

When a row has no match on the opposite side (in LEFT, RIGHT, or FULL joins), the missing columns are filled with Value::Null. Each JoinSide builds one NULL row from the compile-time column definitions supplied by DatabaseSchema::table_columns:

#![allow(unused)]
fn main() {
null_row: columns.iter().map(|column| (*column, Value::Null)).collect()
}

This preserves the complete row shape and column definitions even when the opposite table contains no rows. The derive-generated DatabaseSchema implementation supplies this metadata. Handwritten implementations remain source-compatible through the trait’s default method, but must override table_columns to execute joins.


Column Resolution

Column references in join ON conditions, filters, and ordering can be either qualified or unqualified:

  • Qualified: "users.id" — explicitly specifies the table.
  • Unqualified: "id" — defaults to the FROM table (for ON left-column) or the joined table (for ON right-column).

Resolution is handled by resolve_column_ref:

#![allow(unused)]
fn main() {
fn resolve_column_ref(&self, field: &str, default_table: &str) -> (String, &str) {
    if let Some((table, column)) = field.split_once('.') {
        (table.to_string(), column)
    } else {
        (default_table.to_string(), field)
    }
}
}

For filters and ordering on joined results, the same qualified/unqualified pattern applies. Filter validation rejects an unqualified name that exists in multiple joined tables and asks the caller to qualify it with a table name.


Output Format

Join results are returned as a JoinResultSet:

#![allow(unused)]
fn main() {
pub struct JoinResultSet {
    pub columns: Vec<JoinColumnDef>, // one per selected column, `table` set
    pub rows: Vec<Vec<Value>>,       // rows[r][c] belongs to columns[c]
}
}

JoinColumnDef mirrors ColumnDef with owned strings and a table field naming the source table. Storing it once per query instead of once per cell is what keeps materialization cheap: a 10,000-row join with eight columns would otherwise allocate the strings of 80,000 column definitions.

JoinResultSet::column_index("table.column") resolves a name to a position; JoinRow (from row(i) or iter()) offers get("table.column") and iter() over (column, value) pairs. A bare name resolves to the first column with that name in row order.

The SQL engine converts a JoinResultSet into SqlResult::Rows, whose rows keep the (JoinColumnDef, Value) pair shape.


Limitations

  • Hash join on equality only: The build side is always the right table of the clause; there is no join reordering or build-side selection by table size.
  • Index use limited to the push-down: The join itself matches rows in memory. When the right join column leads an index, the left keys are pushed down so the right-side read uses it; otherwise the right table is scanned in full.
  • All rows loaded into memory: Every table involved in the join is fully materialized in memory before processing. This can be a concern for very large tables.
  • Equality joins only: The ON condition only supports column equality (left_col = right_col). Range conditions, expressions, and multi-column ON clauses are not supported.

Atomicity


Overview

A DBMS must guarantee atomicity: either all writes in an operation succeed, or none of them persist. Without atomicity, a crash or error mid-operation can leave the database in an inconsistent state (e.g., a record written but the page ledger not updated, or half the rows in a transaction committed while the rest are lost).


The Problem

The original atomic() implementation relied on panic semantics:

#![allow(unused)]
fn main() {
fn atomic<F, R>(&self, f: F) -> R
where
    F: FnOnce(&WasmDbmsDatabase<M>) -> DbmsResult<R>,
{
    match f(self) {
        Ok(res) => res,
        Err(err) => panic!("{err}"),
    }
}
}

Some runtimes, such as the Internet Computer, automatically revert all memory writes made by a call that panics. On other WASM runtimes (e.g., Wasmtime, Wasmer, browser WASM), a panic does not revert memory. The host simply sees the guest abort, and any writes already flushed to linear memory remain. Without extra protection, this would make wasm-dbms safe for write operations only on runtimes that revert on panic.


Write-Ahead Journal

The fix is a write-ahead journal. Before overwriting any bytes, the journal saves the original content at that offset. On error, the journal replays saved entries in reverse order, restoring every modified byte.

Architecture

The journal lives in the wasm-dbms crate’s transaction module, not in the memory layer. This separation keeps the memory crate (wasm-dbms-memory) focused on page-level I/O while the DBMS layer owns the transaction concern.

The key types are:

  • MemoryAccess trait (in wasm-dbms-memory): Abstracts page-level read/write operations. MemoryManager implements this trait with direct writes.
  • Journal (in wasm-dbms): A heap-only collection of JournalEntry records. Each entry stores the page, offset, and original bytes before a write.
  • JournaledWriter (in wasm-dbms): Wraps a &mut MemoryManager and a &mut Journal, implementing MemoryAccess. Every write_at or zero call reads the original bytes first, records them in the journal, then delegates to the underlying MemoryManager.

All memory-crate functions that perform writes (in TableRegistry, PageLedger, FreeSegmentsLedger, etc.) are generic over impl MemoryAccess. When called with a plain MemoryManager, writes go directly to memory. When called with a JournaledWriter, writes are automatically recorded for rollback.

Journal Flow

┌─────────────────┐
│  Journal::new() │   Creates empty journal
└────────┬────────┘
         │
         ▼
┌─────────────────────────┐
│  JournaledWriter wraps  │
│  MemoryManager + Journal│
└────────┬────────────────┘
         │
         ▼
┌──────────────┐
│   write_at   │──► Reads original bytes, records in journal, then writes new data
│     zero     │──► Reads original bytes, records in journal, then writes zeros
└──────┬───────┘
       │
       ├── success ──► journal.commit()   ──► Drops entries (no-op)
       │
       └── error   ──► journal.rollback() ──► Replays entries in reverse via MemoryManager

Each journal entry is:

#![allow(unused)]
fn main() {
struct JournalEntry {
    page: Page,
    offset: PageOffset,
    original_bytes: Vec<u8>,
}
}

What is Journaled

OperationJournaled?Why
write_atYesModifies existing data that must be restorable
zeroYesModifies existing data (writes zeros)
allocate_pageNoNewly allocated pages are unreferenced after rollback; their content is irrelevant

Transaction Commit Atomicity

When a transaction is committed, all buffered operations (inserts, updates, deletes) are flushed to memory. Previously, each operation was wrapped in its own atomic() call. If operation 3 of 5 failed, operations 1 and 2 were already persisted and could not be undone.

Now, commit() uses a single journal spanning all operations:

#![allow(unused)]
fn main() {
fn commit(&mut self) -> DbmsResult<()> {
    // ... take transaction ...

    *self.ctx.journal.borrow_mut() = Some(Journal::new());

    for op in transaction.operations {
        let result = match op { /* execute insert/update/delete */ };

        if let Err(err) = result {
            if let Some(journal) = self.ctx.journal.borrow_mut().take() {
                journal
                    .rollback(&mut self.ctx.mm.borrow_mut())
                    .expect("critical: failed to rollback journal");
            }
            return Err(err);
        }
    }

    if let Some(journal) = self.ctx.journal.borrow_mut().take() {
        journal.commit();
    }
    Ok(())
}
}

This ensures that either all transaction operations are applied, or none of them persist, regardless of the WASM runtime.


Edge Cases

Page Allocation

allocate_page writes directly via the memory provider, bypassing the journal. This is intentional: a newly allocated page has no meaningful prior content to restore, and after a rollback, nothing references it (the page ledger update that would have pointed to it was itself journaled and rolled back). The page remains allocated but unused — a minor space leak that is acceptable since it will be reused by subsequent allocations.

Nested Atomic Calls

During commit(), each transaction operation is dispatched through the Database trait methods (insert, update, delete), which internally call atomic(). Since commit() has already placed a Journal in DbmsContext, atomic() detects this via self.ctx.journal.borrow().is_some() and delegates to the outer journal instead of starting its own. This ensures a single journal spans the entire commit.

Rollback Failure

If journal.rollback() itself fails (e.g., the memory provider returns an I/O error during the restore writes), the program panics. A failed rollback means memory is in an indeterminate state — some bytes restored, some not. There is no recovery path, so immediate termination is the only safe response (per M-PANIC-ON-BUG).

WASI Memory Provider


Overview

The wasi-dbms-memory crate provides WasiMemoryProvider, a persistent file-backed implementation of the MemoryProvider trait. It enables wasm-dbms databases to run on any WASI-compliant runtime (Wasmer, Wasmtime, WasmEdge, etc.) with durable data persistence across process restarts.

The provider stores all database pages in a single flat file on the filesystem. Each page is 64 KiB (65,536 bytes), matching the WASM memory page size.


Installation

Add wasi-dbms-memory to your Cargo.toml:

[dependencies]
wasi-dbms-memory = "0.9"

The crate depends on wasm-dbms-api and wasm-dbms-memory (pulled in transitively).


Usage

Creating a Provider

#![allow(unused)]
fn main() {
use wasi_dbms_memory::WasiMemoryProvider;
use wasm_dbms_memory::MemoryProvider;

// Opens the file if it exists, or creates it empty.
let mut provider = WasiMemoryProvider::new("./data/mydb.bin").unwrap();

// Allocate pages as needed.
provider.grow(1).unwrap(); // 1 page = 64 KiB

// Read and write at arbitrary offsets.
provider.write(0, b"hello").unwrap();

let mut buf = vec![0u8; 5];
provider.read(0, &mut buf).unwrap();
assert_eq!(&buf, b"hello");
}

When opening an existing file, the page count is inferred from the file size. The file size must be a multiple of 64 KiB; otherwise WasiMemoryProvider::new returns an error.

The parent directory must already exist before creating the provider.

For hosts with persistent key-value storage instead of a filesystem, use the WASI Key-Value Memory Provider.

Using with DbmsContext

Pass the provider directly to DbmsContext:

#![allow(unused)]
fn main() {
use wasi_dbms_memory::WasiMemoryProvider;
use wasm_dbms::DbmsContext;

let provider = WasiMemoryProvider::new("./data/mydb.bin").unwrap();
let ctx = DbmsContext::new(provider);
}

From this point on, all database operations use the file as persistent storage.

TryFrom Conversions

The crate provides TryFrom implementations for convenient construction:

#![allow(unused)]
fn main() {
use std::path::{Path, PathBuf};
use wasi_dbms_memory::WasiMemoryProvider;

// From &Path
let provider = WasiMemoryProvider::try_from(Path::new("./mydb.bin")).unwrap();

// From PathBuf
let path = PathBuf::from("./mydb.bin");
let provider = WasiMemoryProvider::try_from(path).unwrap();
}

File Layout and Portability

The backing file is a contiguous sequence of 64 KiB pages, zero-filled on allocation. This means database snapshots are portable between different MemoryProvider implementations.

A file created by WasiMemoryProvider can be loaded by any provider that uses the same page layout, and vice versa. This enables workflows such as:

  • Exporting a database from another runtime and loading it locally for debugging
  • Developing and testing with WASI, then deploying to another runtime
  • Migrating data between different WASM runtimes

Error Handling

All operations return MemoryResult<T> (an alias for Result<T, MemoryError>). The possible errors are:

ErrorCause
MemoryError::OutOfBoundsRead or write beyond allocated memory
MemoryError::FailedToAllocatePageRequested growth overflows a u64 byte size
MemoryError::ProviderError(String)File I/O failure, or file size not page-aligned

Concurrency

WasiMemoryProvider assumes single-writer access. WASM is single-threaded by default, so this is generally not a concern. If you run multiple instances pointing at the same file, you are responsible for external synchronization. WASI file-lock support varies across runtimes.


Comparison with Other Providers

ProviderUse caseBacking storage
WasiMemoryProviderWASI productionSingle flat file on filesystem
HeapMemoryProviderTestingIn-process Vec<u8>
WasiKeyValueMemoryProviderWASI key-value hostDraft2 bucket with checkpoints

Both share the same page layout, so data is portable across implementations.

WASI Key-Value Memory Provider

wasi-dbms-key-value-memory stores the wasm-dbms page memory in a WASI key-value bucket. It targets the draft2 interfaces wasi:keyvalue/store@0.2.0-draft2 and wasi:keyvalue/batch@0.2.0-draft2. Use a host that implements both interfaces; a host implementing only an older draft is not compatible.

When to Use It

Choose this provider when the runtime owns persistent key-value storage and does not offer a useful filesystem. The provider keeps the database engine unchanged and supplies the host bucket as a MemoryProvider.

Use wasi-dbms-memory when a single file and a WASI filesystem preopen are the simplest durable storage choice.

Usage

Add the crate and open a bucket/database namespace:

#![allow(unused)]
fn main() {
use wasi_dbms_key_value_memory::WasiKeyValueMemoryProvider;
use wasm_dbms::DbmsContext;

let provider = WasiKeyValueMemoryProvider::new("default", "issue-176")?;
let ctx = DbmsContext::new(provider);
// Register the schema, run committed operations, then publish a checkpoint.
ctx.flush()?;
Ok::<(), Box<dyn std::error::Error>>(())
}

The bucket identifier is passed unchanged to store.open. The database name is encoded into the namespace, so names cannot collide through / or other separator characters. A native test can inject a KeyValueStore with WasiKeyValueMemoryProvider::with_store.

Checkpoint and Restart Model

The provider caches the complete logical memory. It records dirty pages in inactive slots and writes them in batches of at most 16 values. It then writes one head manifest containing the magic WDBMSKV2, the current and previous page counts, and a slot plus XXH3 hash for every referenced page. The head is the commit point.

Transactions and durable checkpoints are separate boundaries:

  • commit() makes the transaction’s changes part of the DBMS committed view.
  • ctx.flush() publishes dirty committed pages to the key-value host.
  • An active transaction overlay is not materialized by flush().

If a page batch fails, the old head and all dirty flags remain available for a retry. If head publication reports an error, the provider is poisoned and the embedder must drop and reopen it. The head may already have been applied, so a reopen can observe the old or new complete checkpoint. A reopen validates every current page and falls back to a usable, non-empty previous checkpoint if replication exposes a stale or missing current page, so it never loads a mixture. If the current checkpoint cannot be validated, opening fails when the previous checkpoint is absent, empty, or invalid. Fallback checkpoints remain readable but reject writes for that provider instance, even if current pages become visible later. Reopen the provider to validate the current checkpoint and resume writes. This prevents a stale reader from replacing a newer checkpoint.

export_snapshot() includes unflushed cached bytes. import_snapshot() only accepts page-aligned bytes into an empty provider and still requires flush().

Limits and Host Requirements

  • The cache is the full database, not a streaming page cache.
  • Page count is capped at u32::MAX and allocations must fit the host address space.
  • Values are raw 65,536-byte pages; host value-size and batch-size limits must accommodate them.
  • The host must provide draft2 store and batch with read-your-writes behavior.
  • Coordinate a single writer and provide durable bucket backing across restarts.
  • No filesystem preopen is required by the provider.

File Provider Alternative

For a WASI file-backed provider, see the WASI Memory Provider.