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

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.