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

ic-dbms


Overview

IC-DBMS is an adapter layer that brings the wasm-dbms relational database engine to the Internet Computer (IC). While wasm-dbms provides the core database functionality (tables, CRUD operations, transactions, memory management), ic-dbms adds everything needed to run it as an IC canister:

  • Candid serialization for all types and API endpoints
  • Canister lifecycle management (init, upgrade, inspect)
  • ACL-based access control using IC principals
  • Procedural macros to generate complete canister APIs from schema definitions
  • Client libraries for inter-canister calls, external agent access, and integration testing

If you are using wasm-dbms outside the Internet Computer (e.g., in a standalone WASM runtime), you do not need ic-dbms. See the generic wasm-dbms documentation instead.


Architecture

┌─────────────────────────────────────────────────┐
│              Your Application                    │
│  (Frontend canister, backend canister, CLI, etc.)│
└──────────────────────┬──────────────────────────┘
                       │  Candid calls
                       ▼
┌─────────────────────────────────────────────────┐
│           ic-dbms-client                         │
│  (IcDbmsCanisterClient / IcDbmsAgentClient /     │
│   IcDbmsPocketIcClient)                          │
└──────────────────────┬──────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────┐
│           ic-dbms-canister                       │
│  (Generated canister API, ACL, init/upgrade)     │
│                                                  │
│  ┌───────────────────────────────────────────┐   │
│  │           wasm-dbms (core engine)          │   │
│  │  Tables, CRUD, Transactions, Memory Mgmt  │   │
│  └───────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

Crates

IC-DBMS is composed of four crates:

CrateDescriptionDepends On
ic-dbms-apiShared types, re-exports wasm-dbms-api types with IC additions. Provides IcDbmsError type alias and IC-compatible type wrappers.wasm-dbms-api
ic-dbms-canisterCore canister engine. Provides the DbmsCanister derive macro target, ACL management, canister init/upgrade lifecycle, and the IC stable memory provider.wasm-dbms, ic-dbms-api
ic-dbms-macrosProcedural macros: #[derive(DatabaseSchema)] (IC variant, uses IC crate paths) and #[derive(DbmsCanister)] for generating complete canister APIs.wasm-dbms-macros
ic-dbms-clientClient library with three implementations: IcDbmsCanisterClient (inter-canister), IcDbmsAgentClient (external via IC agent), IcDbmsPocketIcClient (integration testing).ic-dbms-api

Import convention:

#![allow(unused)]
fn main() {
// In your schema crate
use ic_dbms_api::prelude::*; // Re-exports wasm_dbms_api types
// In your canister crate
use ic_dbms_canister::prelude::DbmsCanister;
// In your client code
use ic_dbms_client::{Client as _, IcDbmsCanisterClient};
}

Quick Start

  1. Define your schema with IC-compatible derives:
#![allow(unused)]
fn main() {
use candid::{CandidType, Deserialize};
use ic_dbms_api::prelude::*;

#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
    pub email: Text,
}
}
  1. Generate the canister:
#![allow(unused)]
fn main() {
use ic_dbms_canister::prelude::{DatabaseSchema, DbmsCanister};

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

ic_cdk::export_candid!();
}
  1. Build, deploy, and interact:
cargo build --target wasm32-unknown-unknown --release
dfx deploy my_dbms --argument '(variant { Init = record { allowed_principals = vec { principal "your-principal" } } })'
#![allow(unused)]
fn main() {
let client = IcDbmsCanisterClient::new(canister_id);
client.insert::<User>(User::table_name(), user, None).await??;
}

For the full walkthrough, see the Get Started guide.


Guides

For core wasm-dbms guides (querying, transactions, relationships, validators, sanitizers, custom data types), see the generic guides.


Reference

  • Schema (IC) - DbmsCanister macro, Candid API generation, IC-specific derives
  • Data Types (IC) - Principal type, Candid type mappings
  • Errors (IC) - IcDbmsError alias, double-Result pattern, client error handling

For the complete reference (all data types, error variants, sanitizers, validators, JSON operations), see the generic reference.

Get Started with IC-DBMS (IC)

Note: This is the IC-specific getting started guide for deploying wasm-dbms as an Internet Computer canister. For the generic wasm-dbms getting started guide (schema definition, core concepts), see the generic get-started guide.

This guide walks you through setting up a complete database canister on the Internet Computer using ic-dbms. The ic-dbms framework is built on top of the wasm-dbms core engine, adding IC-specific functionality such as Candid serialization, canister lifecycle management, ACL-based access control, and inter-canister communication. By the end of this guide, you will have a working canister with CRUD operations, transactions, and access control.


Prerequisites

Before starting, ensure you have:

  • Rust 1.91.1 or later
  • wasm32-unknown-unknown target: rustup target add wasm32-unknown-unknown
  • dfx (Internet Computer SDK)
  • ic-wasm: cargo install ic-wasm
  • candid-extractor: cargo install candid-extractor

Project Setup

Workspace Structure

We recommend organizing your project as a Cargo workspace with two crates:

my-dbms-project/
├── Cargo.toml          # Workspace manifest
├── schema/             # Schema definitions (reusable types)
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs
└── canister/           # The DBMS canister
    ├── Cargo.toml
    └── src/
        └── lib.rs

Workspace Cargo.toml:

[workspace]
members = ["schema", "canister"]
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]
candid = "0.10"
ic-dbms-api = "0.6"
serde = "1"

Note: ic-dbms-api re-exports types from wasm-dbms-api, so use ic_dbms_api::prelude::* gives you access to the full set of wasm-dbms data types, validators, and sanitizers.

Define Tables

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

#![allow(unused)]
fn main() {
use candid::{CandidType, Deserialize};
use ic_dbms_api::prelude::*;

#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[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, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[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, CandidType, Deserialize, Clone. The #[candid] attribute ensures generated types (Record, InsertRequest, UpdateRequest) also derive Candid/Serde traits.

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

Create the DBMS Canister

Canister Dependencies

Create canister/Cargo.toml:

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

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.10"
ic-cdk = "0.19"
ic-dbms-api = "0.6"
ic-dbms-canister = "0.6"
my-schema = { path = "../schema" }
serde = "1"

Generate the Canister API

In canister/src/lib.rs:

#![allow(unused)]
fn main() {
use ic_dbms_canister::prelude::{DatabaseSchema, DbmsCanister};
use my_schema::{Post, User};

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

ic_cdk::export_candid!();
}

The DatabaseSchema derive generates the DatabaseSchema<M> trait implementation that provides schema dispatch ( routing operations to the correct table by name). The DbmsCanister derive generates the complete canister API:

service : (IcDbmsCanisterArgs) -> {
  // ACL Management
  acl_add_principal : (principal) -> (Result);
  acl_allowed_principals : () -> (vec principal) query;
  acl_remove_principal : (principal) -> (Result);

  // Transactions
  begin_transaction : () -> (nat);
  commit : (nat) -> (Result);
  rollback : (nat) -> (Result);

  // Users CRUD
  insert_users : (UserInsertRequest, opt nat) -> (Result);
  select_users : (Query, opt nat) -> (Result_1) query;
  update_users : (UserUpdateRequest, opt nat) -> (Result_2);
  delete_users : (DeleteBehavior, opt Filter, opt nat) -> (Result_2);

  // Posts CRUD
  insert_posts : (PostInsertRequest, opt nat) -> (Result);
  select_posts : (Query, opt nat) -> (Result_3) query;
  update_posts : (PostUpdateRequest, opt nat) -> (Result_2);
  delete_posts : (DeleteBehavior, opt Filter, opt nat) -> (Result_2);
}

Build the Canister

Create a build script or use the following commands:

# Build the canister
cargo build --target wasm32-unknown-unknown --release -p my-canister

# Optimize the WASM
ic-wasm target/wasm32-unknown-unknown/release/my_canister.wasm \
    -o my_canister.wasm shrink

# Extract Candid interface
candid-extractor my_canister.wasm > my_canister.did

# Optionally compress
gzip -k my_canister.wasm --force

Deploy the Canister

Canister Init Arguments

The canister requires initialization arguments specifying which principals can access the database:

type IcDbmsCanisterArgs = variant {
  Init : IcDbmsCanisterInitArgs;
  Upgrade;
};

type IcDbmsCanisterInitArgs = record {
  allowed_principals : vec principal;
};

Warning: Only principals in allowed_principals can perform database operations. Make sure to include all necessary principals (your frontend canister, admin principal, etc.).

Deploy with dfx

Create dfx.json:

{
  "canisters": {
    "my_dbms": {
      "type": "custom",
      "candid": "my_canister.did",
      "wasm": "my_canister.wasm",
      "build": []
    }
  }
}

Deploy:

dfx deploy my_dbms --argument '(variant { Init = record { allowed_principals = vec { principal "your-principal-here" } } })'

Quick Example: Complete Workflow

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

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::*;
use ic_dbms_client::{Client as _, IcDbmsCanisterClient};
use my_schema::{User, UserInsertRequest, UserUpdateRequest};

async fn example(canister_id: Principal) -> Result<(), Box<dyn std::error::Error>> {
    let client = IcDbmsCanisterClient::new(canister_id);

    // 1. INSERT a new user
    let insert_req = UserInsertRequest {
        id: 1.into(),
        name: "Alice".into(),
        email: "alice@example.com".into(),
        created_at: DateTime::now(),
    };
    client
        .insert::<User>(User::table_name(), insert_req, None)
        .await??;

    // 2. SELECT users
    let query = Query::builder()
        .filter(Filter::eq("name", Value::Text("Alice".into())))
        .build();
    let users = client
        .select::<User>(User::table_name(), query, None)
        .await??;
    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 = client
        .update::<User>(User::table_name(), update_req, None)
        .await??;
    println!("Updated {} record(s)", updated);

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

    Ok(())
}
}

Integration Testing

For integration tests using PocketIC, add ic-dbms-client with the pocket-ic feature:

[dev-dependencies]
ic-dbms-client = { version = "0.9", features = ["pocket-ic"] }
pocket-ic = "9"

Example test:

#![allow(unused)]
fn main() {
use ic_dbms_client::prelude::{Client as _, IcDbmsPocketIcClient};
use my_schema::{User, UserInsertRequest};
use pocket_ic::PocketIc;

#[tokio::test]
async fn test_insert_and_select() {
    let pic = PocketIc::new();
    // ... setup canister ...

    let client = IcDbmsPocketIcClient::new(canister_id, admin_principal, &pic);

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

    client
        .insert::<User>(User::table_name(), insert_req, None)
        .await
        .expect("call failed")
        .expect("insert failed");

    let query = Query::builder().all().build();
    let users = client
        .select::<User>(User::table_name(), query, None)
        .await
        .expect("call failed")
        .expect("select failed");

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

Next Steps

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

For core wasm-dbms concepts (querying, transactions, relationships, validators, sanitizers), see the generic guides.

CRUD Operations (IC)

Note: This is the IC-specific CRUD operations guide, covering usage via the ic-dbms-client. For core CRUD concepts (filtering, delete behaviors, error types), see the generic CRUD operations guide.


Overview

ic-dbms provides four fundamental database operations, accessed through the ic-dbms-client crate’s Client trait. All operations use Candid serialization under the hood and support the IC’s inter-canister call model.

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:

  • Respect access control (caller must be in ACL)
  • Support optional transaction IDs
  • Validate and sanitize data according to schema rules
  • Enforce foreign key constraints
  • Return a double Result (see Error Handling)

Insert

Basic Insert

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

#![allow(unused)]
fn main() {
use ic_dbms_client::{IcDbmsCanisterClient, Client as _};
use my_schema::{User, UserInsertRequest};
use ic_dbms_api::prelude::*;

let client = IcDbmsCanisterClient::new(canister_id);

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

// Insert without transaction (None)
client
    .insert::<User>(User::table_name(), user, None)
    .await??;
}

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
client.insert::<User>(User::table_name(), user1, None).await??;

// Second insert with same ID fails with PrimaryKeyConflict
let result = client.insert::<User>(User::table_name(), user2_same_id, None).await?;
assert!(matches!(result, Err(IcDbmsError::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, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[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
};

client.insert::<Profile>(Profile::table_name(), profile, None).await??;
}

Insert with Transaction

To insert within a transaction, pass the transaction ID:

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

// Insert within transaction
client.insert::<User>(User::table_name(), user, Some(tx_id)).await??;

// Commit or rollback
client.commit(tx_id).await??;
}

Select

Select All Records

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

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

let query = Query::builder().all().build();
let users: Vec<UserRecord> = client
    .select::<User>(User::table_name(), query, None)
    .await??;

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 = client.select::<User>(User::table_name(), query, None).await??;
}

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 = client.select::<User>(User::table_name(), query, None).await??;
// 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 = client.select::<Post>(Post::table_name(), query, None).await??;
}

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 = client
    .update::<User>(User::table_name(), update, None)
    .await??;

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();

client.update::<User>(User::table_name(), update, None).await??;
}

Update with Filter

The filter determines which records are updated:

#![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 = client.update::<User>(User::table_name(), update, None).await??;
println!("Verified {} company users", affected);
}

Update Return Value

Update returns the number of affected rows:

#![allow(unused)]
fn main() {
let affected = client.update::<User>(User::table_name(), update, None).await??;

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

Delete

Delete with Filter

Delete records matching a filter:

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

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

let deleted = client
    .delete::<User>(
        User::table_name(),
        DeleteBehavior::Restrict,
        Some(filter),
        None  // No transaction
    )
    .await??;

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 = client.delete::<User>(
    User::table_name(),
    DeleteBehavior::Restrict,
    Some(Filter::eq("id", Value::Uint32(1.into()))),
    None
).await?;

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

Cascade Example:

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

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 = client
    .delete::<User>(
        User::table_name(),
        DeleteBehavior::Cascade,
        None,  // No filter = all records
        None
    )
    .await??;

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

Operations with Transactions

All CRUD operations accept an optional transaction ID. When provided, the operation is performed within that transaction and won’t be visible to other callers until committed:

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

// Perform operations within transaction
client.insert::<User>(User::table_name(), user1, Some(tx_id)).await??;
client.insert::<User>(User::table_name(), user2, Some(tx_id)).await??;

// Update within same transaction
let update = UserUpdateRequest::builder()
    .set_verified(true.into())
    .filter(Filter::all())
    .build();
client.update::<User>(User::table_name(), update, Some(tx_id)).await??;

// Commit all changes atomically
client.commit(tx_id).await??;
}

See the Transactions Guide for comprehensive transaction documentation.


Error Handling

CRUD operations via the IC client return a double Result: Result<Result<T, IcDbmsError>, CallError>.

  • Outer Result: Network/canister call errors (canister unreachable, cycles exhausted)
  • Inner Result: Database logic errors (validation, constraint violations, etc.)

Use ?? to propagate both:

#![allow(unused)]
fn main() {
client.insert::<User>(User::table_name(), user, None).await??;
}

Or handle each layer explicitly:

#![allow(unused)]
fn main() {
match client.insert::<User>(User::table_name(), user, None).await {
    Ok(Ok(())) => println!("Insert successful"),
    Ok(Err(db_error)) => {
        // Handle database errors
        match db_error {
            IcDbmsError::Query(QueryError::PrimaryKeyConflict) => {
                println!("User already exists");
            }
            IcDbmsError::Validation(msg) => {
                println!("Validation error: {}", msg);
            }
            _ => println!("Database error: {:?}", db_error),
        }
    }
    Err(call_error) => {
        // Handle network/call errors
        println!("Failed to call canister: {:?}", call_error);
    }
}
}

Common error types:

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

See the Errors Reference (IC) for complete IC-specific error documentation, or the generic Errors Reference for the full error hierarchy.

Access Control (IC)

Note: This is the IC-specific access control guide. Access control is an IC-only feature.

ic-dbms uses a granular Access Control List (ACL) keyed by Principal. Each identity carries an IdentityPerms record:

FieldTypeMeaning
adminboolBypass all per-table checks. Does NOT imply other ops.
manage_aclboolGrant/revoke perms; add/remove identities.
migrateboolRun migrate / pending_migrations / has_drift.
all_tablesTablePermsPer-op bits applied to every table.
per_tableVec<(Table, TablePerms)>Per-table additive grants.

TablePerms is a u8 bitfield over READ, INSERT, UPDATE, DELETE.

admin bypasses table checks but does not silently elevate to manage_acl or migrate — a data admin cannot escalate to ACL/ops roles by accident.

Initialization

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::IcDbmsCanisterInitArgs;

let args = IcDbmsCanisterInitArgs {
    allowed_principals: Some(vec![operator_principal]),
};
}

Bootstrap rules:

allowed_principalsResult
NoneDeployer principal becomes a full admin.
Some(vec![])Same as None — deployer becomes a full admin.
Some(vec![p, q])Each listed principal becomes a full admin.

A “full admin” carries admin = true, manage_acl = true, migrate = true, and all_tables = TablePerms::all().

Endpoints

Operational flags

EndpointRequired permEffect
grant_adminmanage_aclSet admin on target.
revoke_adminmanage_aclClear admin on target.
grant_manage_aclmanage_aclSet manage_acl on target.
revoke_manage_aclmanage_aclClear manage_acl on target.
grant_migratemanage_aclSet migrate on target.
revoke_migratemanage_aclClear migrate on target.

Table perms

EndpointRequired permEffect
grant_all_tables_permsmanage_aclOR perms into all_tables.
revoke_all_tables_permsmanage_aclMask perms out of all_tables.
grant_table_permsmanage_aclOR perms into per_table[table].
revoke_table_permsmanage_aclMask perms out of per_table[table].

Identity lifecycle

EndpointRequired permEffect
remove_identitymanage_aclDrop the identity entirely.
list_identitiesmanage_aclList every identity with its perms.
my_perms(none)Return the caller’s own perms.

CRUD enforcement

#[derive(DbmsCanister)] injects a granted check before each generated endpoint:

Endpoint kindRequired perm
select_* / aggregate_* / selectTablePerms::READ
insert_*TablePerms::INSERT
update_*TablePerms::UPDATE
delete_*TablePerms::DELETE

Effective check: admin || (all_tables | per_table[table]).contains(required).

select_join enforces READ on the root table only. Joined tables are not checked separately in v1.

Migration

EndpointRequired perm
has_driftmigrate
pending_migrationsmigrate
migratemigrate

Transactions

begin_transaction / commit / rollback are unconditional — per-op CRUD checks gate the data accesses inside the transaction. An identity with no perms can open and commit an empty transaction; the moment it tries to read or write, AccessDenied is returned.

Last-manage_acl guard

revoke(ManageAcl) and remove_identity refuse the operation when it would leave the ACL with zero manage_acl-carrying identities:

DbmsError::Memory(MemoryError::ConstraintViolation(
    "at least one identity must retain manage_acl"
))

admin and migrate carry no such guard — they can be re-granted from any manage_acl holder.

Errors

A failed perm check returns:

#![allow(unused)]
fn main() {
DbmsError::AccessDenied {
    table: Option<TableFingerprint>,
    required: RequiredPerm,
}
}

RequiredPerm enumerates the missing perm class:

  • RequiredPerm::Table(TablePerms) — a table operation.
  • RequiredPerm::Admin — admin bypass missing.
  • RequiredPerm::ManageAcl — ACL management missing.
  • RequiredPerm::Migrate — migration missing.

Recipes

Read-only viewer

#![allow(unused)]
fn main() {
client.grant_all_tables_perms(viewer, TablePerms::READ).await?;
}

Per-table writer

#![allow(unused)]
fn main() {
client.grant_table_perms(svc, "users", TablePerms::INSERT | TablePerms::UPDATE).await?;
}

Migration bot

#![allow(unused)]
fn main() {
client.grant_migrate(bot).await?;
}

ACL deputy

#![allow(unused)]
fn main() {
client.grant_manage_acl(deputy).await?;
}

Client API (IC)

Note: This is the IC-specific client API guide. For general wasm-dbms documentation, see the generic docs.


Overview

The ic-dbms-client crate provides type-safe Rust clients for interacting with ic-dbms canisters. Instead of manually constructing Candid calls, you use a high-level API that handles serialization and error handling.

Benefits:

  • Type-safe operations with compile-time checking
  • Automatic Candid encoding/decoding
  • Consistent API across different environments
  • Built-in error handling

Client Types

ic-dbms provides three client implementations for different use cases:

ClientUse CaseFeature Flag
IcDbmsCanisterClientInter-canister calls (inside IC canisters)Default
IcDbmsAgentClientExternal applications (frontend, backend, CLI)ic-agent
IcDbmsPocketIcClientIntegration tests with PocketICpocket-ic

IcDbmsCanisterClient

For calls from one IC canister to another:

#![allow(unused)]
fn main() {
use ic_dbms_client::{IcDbmsCanisterClient, Client as _};
use candid::Principal;

// In your canister code
let dbms_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
let client = IcDbmsCanisterClient::new(dbms_canister_id);

// Use the client
let users = client.select::<User>(User::table_name(), query, None).await??;
}

IcDbmsAgentClient

For external applications using the IC Agent:

#![allow(unused)]
fn main() {
use ic_dbms_client::{IcDbmsAgentClient, Client as _};
use ic_agent::Agent;
use candid::Principal;

// Create an IC Agent (with identity, etc.)
let agent = Agent::builder()
    .with_url("https://ic0.app")
    .with_identity(identity)
    .build()?;

agent.fetch_root_key().await?;  // Only needed for local replica

let dbms_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
let client = IcDbmsAgentClient::new(dbms_canister_id, &agent);

// Use the client
let users = client.select::<User>(User::table_name(), query, None).await??;
}

IcDbmsPocketIcClient

For integration tests using PocketIC:

#![allow(unused)]
fn main() {
use ic_dbms_client::{IcDbmsPocketIcClient, Client as _};
use pocket_ic::PocketIc;
use candid::Principal;

let pic = PocketIc::new();
// ... setup canister ...

let client = IcDbmsPocketIcClient::new(
    canister_id,
    caller_principal,  // The principal making calls
    &pic
);

// Use the client in tests
let users = client.select::<User>(User::table_name(), query, None).await??;
}

Installation

Add ic-dbms-client to your Cargo.toml:

For canister development (inter-canister calls):

[dependencies]
ic-dbms-client = "0.6"

For external applications:

[dependencies]
ic-dbms-client = { version = "0.9", features = ["ic-agent"] }

For integration tests:

[dev-dependencies]
ic-dbms-client = { version = "0.9", features = ["pocket-ic"] }

The Client Trait

All clients implement the Client trait, providing a consistent API:

#![allow(unused)]
fn main() {
pub trait Client {
    // CRUD Operations
    async fn insert<T: Table>(
        &self,
        table: &str,
        record: T::InsertRequest,
        tx: Option<u64>,
    ) -> Result<Result<(), IcDbmsError>>;
    async fn select<T: Table>(
        &self,
        table: &str,
        query: Query<T>,
        tx: Option<u64>,
    ) -> Result<Result<Vec<T::Record>, IcDbmsError>>;
    async fn aggregate<T: Table>(
        &self,
        table: &str,
        query: Query,
        aggregates: Vec<AggregateFunction>,
        tx: Option<u64>,
    ) -> Result<Result<Vec<AggregatedRow>, IcDbmsError>>;
    async fn update<T: Table>(
        &self,
        table: &str,
        update: T::UpdateRequest,
        tx: Option<u64>,
    ) -> Result<Result<u64, IcDbmsError>>;
    async fn delete<T: Table>(
        &self,
        table: &str,
        behavior: DeleteBehavior,
        filter: Option<Filter>,
        tx: Option<u64>,
    ) -> Result<Result<u64, IcDbmsError>>;

    // Transactions
    async fn begin_transaction(&self) -> Result<u64>;
    async fn commit(&self, tx: u64) -> Result<Result<(), IcDbmsError>>;
    async fn rollback(&self, tx: u64) -> Result<Result<(), IcDbmsError>>;

    // ACL Management
    async fn acl_add_principal(&self, principal: Principal) -> Result<Result<(), IcDbmsError>>;
    async fn acl_remove_principal(&self, principal: Principal) -> Result<Result<(), IcDbmsError>>;
    async fn acl_allowed_principals(&self) -> Result<Vec<Principal>>;

    // Schema Migrations
    async fn has_drift(&self) -> Result<Result<bool, IcDbmsError>>;
    async fn pending_migrations(&self) -> Result<Result<Vec<MigrationOp>, IcDbmsError>>;
    async fn migrate(&self, policy: MigrationPolicy) -> Result<Result<(), IcDbmsError>>;
}
}

Note the double Result:

  • Outer Result: Network/communication errors
  • Inner Result: Business logic errors (IcDbmsError)

Operations

Insert

#![allow(unused)]
fn main() {
use ic_dbms_client::Client as _;
use my_schema::{User, UserInsertRequest};

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

// Without transaction
client.insert::<User>(User::table_name(), user, None).await??;

// With transaction
client.insert::<User>(User::table_name(), user, Some(tx_id)).await??;
}

Select

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

// Select all
let query = Query::builder().all().build();
let users: Vec<UserRecord> = client
    .select::<User>(User::table_name(), query, None)
    .await??;

// Select with filter
let query = Query::builder()
    .filter(Filter::eq("status", Value::Text("active".into())))
    .order_by("created_at", OrderDirection::Descending)
    .limit(10)
    .build();
let users = client.select::<User>(User::table_name(), query, None).await??;
}

Aggregate

Aggregate queries dispatch to the per-table aggregate_<table> endpoint generated by DbmsCanister. The pipeline (WHERE -> DISTINCT -> GROUP BY -> aggregate computation -> HAVING -> ORDER BY -> OFFSET/LIMIT) is described in the Query API reference.

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::{AggregateFunction, AggregatedValue, Filter, Query, Uint64, Value};

// COUNT(*) of all rows
let result = client
    .aggregate::<User>(
        User::table_name(),
        Query::default(),
        vec![AggregateFunction::Count(None)],
        None,
    )
    .await??;
assert!(matches!(result[0].values[0], AggregatedValue::Count(_)));

// GROUP BY + HAVING: rows per role, only roles with more than 5 users
let query = Query::builder()
    .group_by(&["role"])
    .having(Filter::gt("agg0", Value::Uint64(Uint64(5))))
    .order_by_desc("agg0")
    .build();
let result = client
    .aggregate::<User>(
        User::table_name(),
        query,
        vec![AggregateFunction::Count(None)],
        None,
    )
    .await??;
}

HAVING and ORDER BY reference aggregate outputs by their positional name agg{N} (agg0 is the first aggregate, agg1 the second, …). They may also reference any column listed in group_by.

Update

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

let update = UserUpdateRequest::builder()
    .set_email("new@example.com".into())
    .filter(Filter::eq("id", Value::Uint32(1.into())))
    .build();

let affected_rows: u64 = client
    .update::<User>(User::table_name(), update, None)
    .await??;
}

Delete

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

// Delete with filter
let deleted: u64 = client
    .delete::<User>(
        User::table_name(),
        DeleteBehavior::Restrict,
        Some(Filter::eq("id", Value::Uint32(1.into()))),
        None
    )
    .await??;

// Delete all (be careful!)
let deleted: u64 = client
    .delete::<User>(
        User::table_name(),
        DeleteBehavior::Cascade,
        None,  // No filter = all records
        None
    )
    .await??;
}

Transactions

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

// Perform operations
client.insert::<User>(User::table_name(), user1, Some(tx_id)).await??;
client.insert::<User>(User::table_name(), user2, Some(tx_id)).await??;

// Commit or rollback
match some_condition {
    true => client.commit(tx_id).await??,
    false => client.rollback(tx_id).await??,
}
}

Schema Migrations

Three admin-gated methods inspect and apply schema drift. The Candid endpoints behind them (has_drift query, pending_migrations query, migrate update) are emitted by #[derive(DbmsCanister)]. See the IC migrations guide for the upgrade workflow.

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::{MigrationOp, MigrationPolicy};

// O(1) once cached on the canister side. True iff a migration is needed.
let drift: bool = client.has_drift().await??;
if !drift {
    return Ok(());
}

// Plan without applying. Always recomputes; safe to call during drift.
let plan: Vec<MigrationOp> = client.pending_migrations().await??;
for op in &plan {
    eprintln!("  {op:?}");
}

// Apply. Refuses DropTable / DropColumn unless allow_destructive is set.
client.migrate(MigrationPolicy::default()).await??;

// Equivalent to:
client
    .migrate(MigrationPolicy { allow_destructive: false })
    .await??;
}

migrate is idempotent — when there is no drift, the call is a cheap no-op.

ACL Management

#![allow(unused)]
fn main() {
use candid::Principal;

// Add principal
let new_principal = Principal::from_text("aaaaa-aa").unwrap();
client.acl_add_principal(new_principal).await??;

// Remove principal
client.acl_remove_principal(new_principal).await??;

// List principals
let allowed = client.acl_allowed_principals().await?;
for p in allowed {
    println!("Allowed: {}", p);
}
}

Error Handling

Client operations return nested Results:

#![allow(unused)]
fn main() {
// Full error handling
match client.insert::<User>(User::table_name(), user, None).await {
    Ok(Ok(())) => {
        println!("Insert successful");
    }
    Ok(Err(db_error)) => {
        // Database error (validation, constraint violation, etc.)
        match db_error {
            IcDbmsError::Query(QueryError::PrimaryKeyConflict) => {
                println!("User with this ID already exists");
            }
            IcDbmsError::Validation(msg) => {
                println!("Validation failed: {}", msg);
            }
            _ => println!("Database error: {:?}", db_error),
        }
    }
    Err(call_error) => {
        // Network/canister call error
        println!("Call failed: {:?}", call_error);
    }
}
}

Simplified with ??:

#![allow(unused)]
fn main() {
// Propagate both error types
client.insert::<User>(User::table_name(), user, None).await??;
}

Examples

Inter-Canister Communication

A backend canister calling the database canister:

#![allow(unused)]
fn main() {
use candid::Principal;
use ic_cdk::update;
use ic_dbms_client::{Client as _, IcDbmsCanisterClient};

const DBMS_CANISTER: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";

#[update]
async fn create_user(name: String, email: String) -> Result<u32, String> {
    let client = IcDbmsCanisterClient::new(Principal::from_text(DBMS_CANISTER).unwrap());

    let user_id = generate_id();
    let user = UserInsertRequest {
        id: user_id.into(),
        name: name.into(),
        email: email.into(),
    };

    client
        .insert::<User>(User::table_name(), user, None)
        .await
        .map_err(|e| format!("Call failed: {:?}", e))?
        .map_err(|e| format!("Insert failed: {:?}", e))?;

    Ok(user_id)
}

#[update]
async fn get_users() -> Result<Vec<UserRecord>, String> {
    let client = IcDbmsCanisterClient::new(Principal::from_text(DBMS_CANISTER).unwrap());

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

    client
        .select::<User>(User::table_name(), query, None)
        .await
        .map_err(|e| format!("Call failed: {:?}", e))?
        .map_err(|e| format!("Query failed: {:?}", e))
}
}

External Application

A CLI tool or backend service:

use candid::Principal;
use ic_agent::Agent;
use ic_agent::identity::BasicIdentity;
use ic_dbms_client::{Client as _, IcDbmsAgentClient};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Load identity from PEM file
    let identity = BasicIdentity::from_pem_file("identity.pem")?;

    // Create agent
    let agent = Agent::builder()
        .with_url("https://ic0.app")
        .with_identity(identity)
        .build()?;

    // For local development, fetch root key
    // agent.fetch_root_key().await?;

    let canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai")?;
    let client = IcDbmsAgentClient::new(canister_id, &agent);

    // List all users
    let query = Query::builder().all().build();
    let users = client
        .select::<User>(User::table_name(), query, None)
        .await??;

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

    Ok(())
}

Integration Testing

Testing with PocketIC:

#![allow(unused)]
fn main() {
use candid::{Principal, encode_one};
use ic_dbms_client::{Client as _, IcDbmsPocketIcClient};
use pocket_ic::PocketIc;

#[tokio::test]
async fn test_user_crud() {
    // Setup PocketIC
    let pic = PocketIc::new();

    // Create and install canister
    let canister_id = pic.create_canister();
    pic.add_cycles(canister_id, 2_000_000_000_000);

    let wasm = std::fs::read("path/to/canister.wasm").unwrap();
    let init_args = IcDbmsCanisterArgs::Init(IcDbmsCanisterInitArgs {
        allowed_principals: vec![admin_principal],
    });

    pic.install_canister(canister_id, wasm, encode_one(init_args).unwrap(), None);

    // Create client
    let client = IcDbmsPocketIcClient::new(canister_id, admin_principal, &pic);

    // Test insert
    let user = UserInsertRequest {
        id: 1.into(),
        name: "Test User".into(),
        email: "test@example.com".into(),
    };
    client
        .insert::<User>(User::table_name(), user, None)
        .await
        .unwrap()
        .unwrap();

    // Test select
    let query = Query::builder().all().build();
    let users = client
        .select::<User>(User::table_name(), query, None)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(users.len(), 1);
    assert_eq!(users[0].name.as_str(), "Test User");

    // Test update
    let update = UserUpdateRequest::builder()
        .set_name("Updated User".into())
        .filter(Filter::eq("id", Value::Uint32(1.into())))
        .build();
    let affected = client
        .update::<User>(User::table_name(), update, None)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(affected, 1);

    // Test delete
    let deleted = client
        .delete::<User>(
            User::table_name(),
            DeleteBehavior::Restrict,
            Some(Filter::eq("id", Value::Uint32(1.into()))),
            None,
        )
        .await
        .unwrap()
        .unwrap();
    assert_eq!(deleted, 1);

    // Verify deletion
    let users = client
        .select::<User>(User::table_name(), Query::builder().all().build(), None)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(users.len(), 0);
}
}

Schema Migrations (IC)

Note: This is the IC-specific migrations guide. The schema-design rules (#[default], #[renamed_from], #[migrate], the Migrate trait, MigrationOp semantics) are identical to the generic backend; see the generic Schema Migrations Guide and the Migrations Reference for the conceptual material. This page covers only what changes when the database lives inside an IC canister.


Overview

A canister upgrade replaces the WASM but keeps stable memory. If the new binary’s #[derive(Table)] schemas differ from the snapshots persisted on disk, the DBMS enters drift state and refuses CRUD until you call migrate. ACL endpoints stay available so you can rotate principals without first healing the schema.

The drift hash is recomputed lazily, on the first has_drift / pending_migrations / CRUD call after boot, and cached on the DBMS context. There is no post-upgrade hook: the canister simply boots, declares drift on first access, and waits for the operator (or a post_upgrade snippet you write yourself) to call migrate.


Generated Endpoints

#[derive(DbmsCanister)] emits three additional endpoints alongside the per-table CRUD methods:

EndpointKindPurpose
has_driftqueryO(1) once cached; true iff a migration is needed.
pending_migrationsqueryReturns the planned Vec<MigrationOp> without applying.
migrateupdatePlans, validates, sorts, and applies the diff atomically.

All three are admin-gated through the same ACL check used by the rest of the CRUD surface — anonymous and unlisted principals are rejected before the DBMS is touched.

migrate is an update because it journals writes. has_drift and pending_migrations are query calls and consume no cycles for the caller beyond the standard query overhead.


Candid Types

The Candid signatures are:

type MigrationPolicy = record { allow_destructive : bool };

type MigrationOp = variant {
  CreateTable   : record { name : text; schema : TableSchemaSnapshot };
  DropTable     : record { name : text };
  AddColumn     : record { table : text; column : ColumnSnapshot };
  DropColumn    : record { table : text; column : text };
  RenameColumn  : record { table : text; old : text; new : text };
  AlterColumn   : record { table : text; column : text; changes : ColumnChanges };
  WidenColumn   : record { table : text; column : text; old_type : DataTypeSnapshot; new_type : DataTypeSnapshot };
  TransformColumn : record { table : text; column : text; old_type : DataTypeSnapshot; new_type : DataTypeSnapshot };
  AddIndex      : record { table : text; index : IndexSnapshot };
  DropIndex     : record { table : text; index : IndexSnapshot };
};

has_drift           : () -> (variant { Ok : bool;             Err : IcDbmsError }) query;
pending_migrations  : () -> (variant { Ok : vec MigrationOp;  Err : IcDbmsError }) query;
migrate             : (MigrationPolicy)
                    -> (variant { Ok;                         Err : IcDbmsError });

Snapshot types (TableSchemaSnapshot, ColumnSnapshot, IndexSnapshot, ForeignKeySnapshot, DataTypeSnapshot, OnDeleteSnapshot, ColumnChanges) are the same Candid records the snapshot reference describes in the generic schema reference. They are exported automatically by ic_cdk::export_candid!().


Upgrade Workflow

The end-to-end flow for a schema-changing release:

  1. Edit the schema. Modify the #[derive(Table)] structs and add #[default] / #[renamed_from] / #[migrate] as needed.
  2. Build the canister. just build_all compiles to wasm32-unknown-unknown, shrinks the WASM, and extracts the new .did.
  3. Deploy via dfx canister install --mode upgrade. Stable memory carries over untouched.
  4. Inspect drift. Call has_drift from dfx, an admin tool, or a Client. Skip the rest if false.
  5. Plan. Call pending_migrations and review the returned ops. Look in particular for unintended DropTable / DropColumn ops, which usually signal a typo in #[table = "..."] or a missing #[renamed_from].
  6. Apply. Call migrate(record { allow_destructive = false }). If the plan contains a deliberate destructive op, set allow_destructive = true only after the review step.
  7. Verify. Re-run has_drift; expect false. CRUD endpoints now work again.

migrate is idempotent: when there is no drift, the call is a cheap no-op.


Calling From a Client

The three methods are part of the Client trait. The signatures are identical across IcDbmsCanisterClient, IcDbmsAgentClient, and IcDbmsPocketIcClient:

#![allow(unused)]
fn main() {
async fn has_drift(&self) -> Result<IcDbmsResult<bool>>;
async fn pending_migrations(&self) -> Result<IcDbmsResult<Vec<MigrationOp>>>;
async fn migrate(&self, policy: MigrationPolicy) -> Result<IcDbmsResult<()>>;
}

The outer Result wraps transport / canister-call failures; the inner IcDbmsResult wraps IcDbmsError (including IcDbmsError::Migration(MigrationError::...)).

Inter-Canister

#![allow(unused)]
fn main() {
use candid::Principal;
use ic_dbms_api::prelude::MigrationPolicy;
use ic_dbms_client::{Client as _, IcDbmsCanisterClient};

#[ic_cdk::update]
async fn heal_schema(canister: Principal) -> Result<u64, String> {
    let client = IcDbmsCanisterClient::new(canister);

    if !client
        .has_drift()
        .await
        .map_err(|e| e.to_string())??
        .then_some(())
        .is_some()
    {
        return Ok(0);
    }

    let ops = client
        .pending_migrations()
        .await
        .map_err(|e| e.to_string())??;
    client
        .migrate(MigrationPolicy::default())
        .await
        .map_err(|e| e.to_string())??;

    Ok(ops.len() as u64)
}
}

External Agent

use candid::Principal;
use ic_agent::Agent;
use ic_dbms_api::prelude::MigrationPolicy;
use ic_dbms_client::{Client as _, IcDbmsAgentClient};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let agent = Agent::builder()
        .with_url("https://ic0.app")
        .with_identity(load_identity()?)
        .build()?;
    let canister = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai")?;
    let client = IcDbmsAgentClient::new(canister, &agent);

    if client.has_drift().await?? {
        let plan = client.pending_migrations().await??;
        eprintln!("planning {} ops", plan.len());
        for op in &plan {
            eprintln!("  {op:?}");
        }
        client
            .migrate(MigrationPolicy {
                allow_destructive: false,
            })
            .await??;
    }

    Ok(())
}

PocketIC Tests

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::MigrationPolicy;
use ic_dbms_client::{Client as _, IcDbmsPocketIcClient};

#[tokio::test]
async fn upgrade_heals_drift() {
    let pic = pocket_ic::PocketIc::new();
    let canister = install_v1_canister(&pic);
    insert_fixtures(&pic, canister).await;

    upgrade_to_v2(&pic, canister);

    let client = IcDbmsPocketIcClient::new(canister, admin_principal(), &pic);
    assert!(client.has_drift().await.unwrap().unwrap());
    let plan = client.pending_migrations().await.unwrap().unwrap();
    assert!(!plan.is_empty());
    client
        .migrate(MigrationPolicy::default())
        .await
        .unwrap()
        .unwrap();
    assert!(!client.has_drift().await.unwrap().unwrap());
}
}

Driving Migration From post_upgrade

For canisters where the deployment pipeline already owns the upgrade flow, you can wire migrate directly into a #[ic_cdk::post_upgrade] hook so the schema heals before the first CRUD call lands.

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::MigrationPolicy;
use ic_dbms_canister::prelude::{DBMS_CONTEXT, DatabaseSchema as _, WasmDbmsDatabase};

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

#[ic_cdk::post_upgrade]
fn post_upgrade() {
    DBMS_CONTEXT.with(|ctx| {
        // Re-register tables so the registry matches the compiled schema
        // before drift detection runs.
        MyCanister::register_tables(ctx).expect("failed to register tables");

        let mut db = WasmDbmsDatabase::oneshot(ctx, MyCanister);
        if db.has_drift().expect("drift check failed") {
            db.migrate(MigrationPolicy::default())
                .expect("migration failed");
        }
    });
}
}

This pattern is convenient but trades safety for convenience:

  • An accidental schema change ships destructive ops to production with no human review.
  • A bug in transform_column traps the canister on upgrade.
  • MigrationPolicy::default() (i.e. allow_destructive: false) refuses destructive ops, but everything else applies silently.

For high-stakes deployments prefer the operator-driven flow below.


Operator-Driven Migration

The recommended flow for production canisters:

  1. Upgrade the canister. The new WASM boots in drift state.
  2. Run a one-shot script (CLI / admin canister / dfx) that calls pending_migrations, prints the plan, and waits for confirmation.
  3. On confirmation, call migrate.

dfx example:

dfx canister call my_dbms has_drift
# (variant { Ok = true })

dfx canister call my_dbms pending_migrations
# (variant { Ok = vec { ... } })

dfx canister call my_dbms migrate '(record { allow_destructive = false })'
# (variant { Ok })

Until migrate succeeds the canister rejects every CRUD endpoint with MigrationError::SchemaDrift, so any traffic that arrives between the upgrade and the operator action receives a clear, structured error.


Error Handling

Migration errors propagate through IcDbmsError::Migration(MigrationError). The variants worth handling explicitly on the client:

VariantMeaningCaller action
SchemaDriftCRUD called while drift is set.Call migrate.
IncompatibleTypeColumn type changed without a widening or transform.Add a transform_column arm or a release that widens via an intermediate type.
DefaultMissingNew non-nullable column with no #[default] or default_value.Add the default; redeploy.
ConstraintViolationTightening rejected an existing row.Backfill the offending rows in a prior release.
DestructiveOpDeniedPlan contained DropTable / DropColumn and policy disallowed it.Re-run with allow_destructive: true after operator review.
TransformAbortedUser transform_column returned Err.Fix the transform; redeploy.
WideningIncompatibleWidenColumn outside the widening whitelist with no transform_column handler.Provide a transform_column impl or split the change across multiple releases.
TransformReturnedNoneMigrate::transform_column returned Ok(None) for a column that needs one.Implement the transform branch.
ForeignKeyViolationAdd-FK tightening found a row referencing a missing target.Clean up orphan rows in a prior release.

Tip: never unwrap migrate in a post_upgrade hook — a panic there bricks the canister. Trap with a descriptive message instead, or fall back to the operator-driven flow.


Drift While Serving Traffic

CRUD endpoints fail fast when drift is set: the very first line of every select_* / insert_* / update_* / delete_* / aggregate_* handler checks the cached drift flag and returns Err(MigrationError::SchemaDrift) without touching the journal. Cost is a single boolean load.

ACL endpoints (acl_add_principal, acl_remove_principal, acl_allowed_principals) bypass the drift check so the operator can rotate keys without first migrating. The migration endpoints themselves are also exempt — pending_migrations is safe to call regardless of state.

After a successful migrate, the in-memory drift flag is cleared inside the same journal session that wrote the new snapshots, so the next CRUD call proceeds against the new schema with no extra round trip.

Schema Reference (IC)

Note: This is the IC-specific schema reference. For complete Table macro details, column attributes, generated types, and best practices, see the generic schema reference.


Overview

When deploying wasm-dbms on the Internet Computer, your schema definitions need additional IC-specific derives, the #[candid] attribute, and a canister generation macro. The core Table macro, column attributes (#[primary_key], #[unique], #[index], #[foreign_key(...)], #[sanitizer(...)], #[validate(...)], #[custom_type], #[alignment], plus the migration attributes #[default], #[renamed_from], #[migrate]), and generated types (Record, InsertRequest, UpdateRequest, ForeignFetcher) work exactly as described in the generic schema reference. This document covers only the IC-specific additions.

Migrations on the IC: schema migrations work the same as on the generic backend, but the DbmsCanister macro additionally emits the has_drift, pending_migrations, and migrate Candid endpoints (see Migration Endpoints below). See the Schema Migrations Reference, the generic Schema Migrations Guide, and the IC Schema Migrations Guide.


IC-Specific Required Derives

Every table struct for IC deployment must include CandidType and Deserialize in addition to the standard Table and Clone derives. You must also add the #[candid] attribute so that generated types (Record, InsertRequest, UpdateRequest) derive CandidType, Serialize, and Deserialize as well:

#![allow(unused)]
fn main() {
use candid::{CandidType, Deserialize};
use ic_dbms_api::prelude::*;

#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    pub name: Text,
}
}
Derive / AttributeRequired for ICPurpose
TableYesGenerates table schema and related types
CandidTypeYes (IC-specific)Enables Candid serialization for the table struct
DeserializeYes (IC-specific)Enables deserialization from Candid wire format
#[candid]Yes (IC-specific)Adds Candid/Serde derives to generated Record, InsertRequest, UpdateRequest types
CloneYesRequired by the macro system
DebugRecommendedUseful for debugging
PartialEq, EqRecommendedUseful for comparisons in tests

Without CandidType, Deserialize, and #[candid], the generated canister API will not compile because Candid is the serialization format used for all IC inter-canister calls.


DatabaseSchema Macro

The DatabaseSchema derive macro generates a DatabaseSchema<M, A> trait implementation that provides schema dispatch – routing database operations to the correct table by name at runtime. This is required by the DbmsCanister macro.

The DatabaseSchema macro is provided by wasm-dbms-macros and re-exported through the ic-dbms-canister prelude.

#![allow(unused)]
fn main() {
use ic_dbms_canister::prelude::{DatabaseSchema, DbmsCanister};
use my_schema::{Post, User};

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

The macro reads the #[tables(...)] attribute and generates:

  • A DatabaseSchema<M, A> trait implementation that dispatches select, insert, update, delete, and select_raw calls to the correct table by name
  • A register_tables associated method for convenient table registration during canister initialization

DbmsCanister Macro

The DbmsCanister macro is an IC-specific procedural macro that generates a complete Internet Computer canister API from your table definitions. It is provided by the ic-dbms-canister crate. It requires the DatabaseSchema derive to also be present on the same struct.

Basic Usage

#![allow(unused)]
fn main() {
use ic_dbms_canister::prelude::{DatabaseSchema, DbmsCanister};
use my_schema::{Comment, Post, User};

#[derive(DatabaseSchema, DbmsCanister)]
#[tables(User = "users", Post = "posts", Comment = "comments")]
pub struct MyDbmsCanister;

ic_cdk::export_candid!();
}

Format: #[tables(StructName = "table_name", ...)]

  • StructName is the Rust struct name (must be in scope via use)
  • "table_name" is the table name matching the #[table = "..."] attribute on the struct

Generated Candid API

For each table, the macro generates five CRUD/aggregate endpoints plus shared transaction and ACL endpoints:

service : (IcDbmsCanisterArgs) -> {
  // Per-table CRUD (example for "users" table)
  insert_users : (UserInsertRequest, opt nat) -> (Result);
  select_users : (Query, opt nat) -> (Result_Vec_UserRecord) query;
  aggregate_users : (Query, vec AggregateFunction, opt nat) -> (Result_Vec_AggregatedRow) query;
  update_users : (UserUpdateRequest, opt nat) -> (Result_u64);
  delete_users : (DeleteBehavior, opt Filter, opt nat) -> (Result_u64);

  // Per-table CRUD (example for "posts" table)
  insert_posts : (PostInsertRequest, opt nat) -> (Result);
  select_posts : (Query, opt nat) -> (Result_Vec_PostRecord) query;
  aggregate_posts : (Query, vec AggregateFunction, opt nat) -> (Result_Vec_AggregatedRow) query;
  update_posts : (PostUpdateRequest, opt nat) -> (Result_u64);
  delete_posts : (DeleteBehavior, opt Filter, opt nat) -> (Result_u64);

  // Transaction methods (shared)
  begin_transaction : () -> (nat);
  commit : (nat) -> (Result);
  rollback : (nat) -> (Result);

  // ACL methods (shared) — granular perms, see Access Control guide
  grant_admin              : (principal) -> (Result);
  revoke_admin             : (principal) -> (Result);
  grant_manage_acl         : (principal) -> (Result);
  revoke_manage_acl        : (principal) -> (Result);
  grant_migrate            : (principal) -> (Result);
  revoke_migrate           : (principal) -> (Result);
  grant_all_tables_perms   : (principal, TablePerms) -> (Result);
  revoke_all_tables_perms  : (principal, TablePerms) -> (Result);
  grant_table_perms        : (principal, text, TablePerms) -> (Result);
  revoke_table_perms       : (principal, text, TablePerms) -> (Result);
  remove_identity          : (principal) -> (Result);
  list_identities          : () -> (Result_Vec_IdentityPerms) query;
  my_perms                 : () -> (IdentityPerms) query;

  // Schema migrations (shared) — see Migration Endpoints below
  has_drift : () -> (Result_bool) query;
  pending_migrations : () -> (Result_Vec_MigrationOp) query;
  migrate : (MigrationPolicy) -> (Result);
}

Method naming convention: {operation}_{table_name} (e.g., insert_users, select_posts, aggregate_users, delete_comments)

Parameter patterns:

  • opt nat is the optional transaction ID
  • select and aggregate methods are query calls (no state changes, no cycles consumed)
  • All other methods are update calls

Aggregate endpoint: aggregate_<table> runs Database::aggregate for that table. The vec AggregateFunction parameter lists COUNT(*) / COUNT(col) / SUM / AVG / MIN / MAX to compute per group; the Query carries group_by, having, order_by, limit, and offset. See the generic Query API reference for type definitions and the aggregate pipeline.

Migration Endpoints

#[derive(DbmsCanister)] adds three admin-gated migration endpoints. Behaviour, error semantics, and the operator workflow are documented in the IC Schema Migrations Guide; this section is the Candid signature reference.

type MigrationPolicy = record {
  allow_destructive : bool;
};

type OnDeleteSnapshot = variant { Restrict; Cascade };

type DataTypeSnapshot = variant {
  Int8; Int16; Int32; Int64;
  Uint8; Uint16; Uint32; Uint64;
  Float32; Float64; Decimal;
  Boolean; Date; Datetime;
  Blob; Text; Uuid; Json;
  Custom : text;
};

type ForeignKeySnapshot = record {
  table : text;
  column : text;
  on_delete : OnDeleteSnapshot;
};

type IndexSnapshot = record {
  columns : vec text;
  unique : bool;
};

type ColumnSnapshot = record {
  name : text;
  data_type : DataTypeSnapshot;
  nullable : bool;
  auto_increment : bool;
  unique : bool;
  primary_key : bool;
  foreign_key : opt ForeignKeySnapshot;
  default : opt Value;
};

type TableSchemaSnapshot = record {
  version : nat8;
  name : text;
  primary_key : text;
  alignment : nat32;
  columns : vec ColumnSnapshot;
  indexes : vec IndexSnapshot;
};

type ColumnChanges = record {
  nullable : opt bool;
  unique : opt bool;
  auto_increment : opt bool;
  primary_key : opt bool;
  foreign_key : opt opt ForeignKeySnapshot;
};

type MigrationOp = variant {
  CreateTable     : record { name : text; schema : TableSchemaSnapshot };
  DropTable       : record { name : text };
  AddColumn       : record { table : text; column : ColumnSnapshot };
  DropColumn      : record { table : text; column : text };
  RenameColumn    : record { table : text; old : text; new : text };
  AlterColumn     : record { table : text; column : text; changes : ColumnChanges };
  WidenColumn     : record { table : text; column : text; old_type : DataTypeSnapshot; new_type : DataTypeSnapshot };
  TransformColumn : record { table : text; column : text; old_type : DataTypeSnapshot; new_type : DataTypeSnapshot };
  AddIndex        : record { table : text; index : IndexSnapshot };
  DropIndex       : record { table : text; index : IndexSnapshot };
};

has_drift          : () -> (variant { Ok : bool;            Err : IcDbmsError }) query;
pending_migrations : () -> (variant { Ok : vec MigrationOp; Err : IcDbmsError }) query;
migrate            : (MigrationPolicy)
                   -> (variant { Ok;                        Err : IcDbmsError });
  • has_drift is O(1) once the per-context drift flag is cached. CRUD endpoints early-return IcDbmsError::Migration(MigrationError::SchemaDrift) while drift is set; ACL and migration endpoints bypass the check.
  • pending_migrations always recomputes the diff. Safe to call during drift.
  • migrate plans, validates against MigrationPolicy, sorts ops into the deterministic apply order, and runs them inside a single journaled session. Failures roll the journal back and leave persisted snapshots untouched.

The IcDbmsError::Migration(MigrationError) variants (SchemaDrift, IncompatibleType, MissingDefault, ConstraintViolation, DestructiveOpDenied, TransformAborted, DataRewriteUnsupported) are documented in the errors reference.

Init arguments:

The generated canister expects IcDbmsCanisterArgs at initialization:

type IcDbmsCanisterArgs = variant {
  Init : IcDbmsCanisterInitArgs;
  Upgrade;
};

type IcDbmsCanisterInitArgs = record {
  allowed_principals : vec principal;
};

Candid Integration

CandidType and Deserialize

These derives are needed because the IC uses Candid as its interface description language. All data crossing canister boundaries must be Candid-serializable.

The ic-dbms-api types (via wasm-dbms-api) already implement CandidType and Deserialize, so your struct only needs the derives:

#![allow(unused)]
fn main() {
use candid::{CandidType, Deserialize};
use ic_dbms_api::prelude::*;

// All field types (Uint32, Text, DateTime, etc.) already implement CandidType
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[table = "events"]
pub struct Event {
    #[primary_key]
    pub id: Uuid,
    pub name: Text,
    pub date: DateTime,
    pub metadata: Nullable<Json>,
}
}

Candid Export

The ic_cdk::export_candid!() macro at the end of your canister lib.rs generates the .did file that describes your canister’s interface. This is required for:

  • dfx deployment
  • Frontend integration
  • Inter-canister calls with type checking
  • Candid UI interaction
#![allow(unused)]
fn main() {
// canister/src/lib.rs
use ic_dbms_canister::prelude::{DatabaseSchema, DbmsCanister};
use my_schema::{Post, User};

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

// This MUST be at the end of the file
ic_cdk::export_candid!();
}

Complete IC Example

#![allow(unused)]
fn main() {
// schema/src/lib.rs
use candid::{CandidType, Deserialize};
use ic_dbms_api::prelude::*;

#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[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, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[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,
}
}
#![allow(unused)]
fn main() {
// canister/src/lib.rs
use ic_dbms_canister::prelude::{DatabaseSchema, DbmsCanister};
use my_schema::{Post, User};

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

ic_cdk::export_candid!();
}

Data Types Reference (IC)

Note: This is the IC-specific data types reference. For the complete list of all data types, usage examples, and general documentation, see the generic data types reference.


Overview

All wasm-dbms data types are available in ic-dbms through ic_dbms_api::prelude::* (which re-exports wasm_dbms_api types). This document covers the IC-specific aspects: the Principal type (which is unique to the Internet Computer) and the Candid type mappings used for canister API serialization.


Principal Type

Principal is an Internet Computer-specific identifier type. It represents a canister ID, user identity, or the anonymous principal. This type is only meaningful in the IC context.

Usage

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

#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[table = "users"]
pub struct User {
    #[primary_key]
    pub id: Uint32,
    #[custom_type]
    pub owner: Principal, // IC principal who owns this record
}
}

Creating principals:

#![allow(unused)]
fn main() {
use candid::Principal;

// From text representation
let principal = Principal::from_text("aaaaa-aa").unwrap();

// Anonymous principal
let anon = Principal::anonymous();

// Caller principal (inside a canister)
let caller = ic_cdk::caller();

// Management canister
let mgmt = Principal::management_canister();
}

Using in insert requests:

#![allow(unused)]
fn main() {
let user = UserInsertRequest {
    id: 1.into(),
    owner: ic_cdk::caller(),  // Store the caller's principal
};

client.insert::<User>(User::table_name(), user, None).await??;
}

Common Patterns

Recording ownership:

#![allow(unused)]
fn main() {
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[table = "documents"]
pub struct Document {
    #[primary_key]
    pub id: Uuid,
    pub title: Text,
    #[custom_type]
    pub owner: Principal, // Who created this
    #[custom_type]
    pub last_editor: Principal, // Who last modified this
}
}

Filtering by principal:

#![allow(unused)]
fn main() {
// Find all documents owned by the caller
let filter = Filter::eq("owner", ic_cdk::caller().into());
let query = Query::builder().filter(filter).build();
let my_docs = client.select::<Document>(Document::table_name(), query, None).await??;
}

Nullable principal (optional ownership):

#![allow(unused)]
fn main() {
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[candid]
#[table = "tasks"]
pub struct Task {
    #[primary_key]
    pub id: Uint32,
    pub title: Text,
    #[custom_type]
    pub assignee: Nullable<Principal>, // May be unassigned
}
}

Candid Type Mapping

When ic-dbms generates the Candid interface (.did file) for your canister, each wasm-dbms type maps to a specific Candid type. This mapping is important for frontend integration, inter-canister calls, and using the Candid UI.

ic-dbms TypeRust TypeCandid TypeNotes
Uint8u8nat8
Uint16u16nat16
Uint32u32nat32
Uint64u64nat64
Int8i8int8
Int16i16int16
Int32i32int32
Int64i64int64
Decimalrust_decimal::DecimaltextSerialized as string for precision
TextStringtext
Booleanboolbool
Datechrono::NaiveDaterecord { year; month; day }Structured record
DateTimechrono::DateTime<Utc>int64Unix timestamp
BlobVec<u8>blob
Principalcandid::PrincipalprincipalIC-specific
Uuiduuid::UuidtextString representation
Jsonserde_json::ValuetextSerialized JSON string
Nullable<T>Option<T>opt TCandid optional

Frontend integration example (JavaScript/TypeScript):

// Calling from a frontend using @dfinity/agent
const user = await actor.select_users({
  filter: [{ Eq: ["name", { Text: "Alice" }] }],
  order_by: [],
  limit: [10n],  // nat64 maps to bigint
  columns: [],
  with_tables: [],
}, []);  // No transaction ID

// Principal values
import { Principal } from "@dfinity/principal";
const owner = Principal.fromText("aaaaa-aa");

ACL Types

The granular ACL exposes four types via Candid:

type TablePerms = nat8;          // Bitfield: READ=1, INSERT=2, UPDATE=4, DELETE=8

type IdentityPerms = record {
  admin       : bool;
  manage_acl  : bool;
  migrate     : bool;
  all_tables  : TablePerms;
  per_table   : vec record { nat64; TablePerms };
};

type PermGrant = variant {
  Admin;
  ManageAcl;
  Migrate;
  AllTables : TablePerms;
  Table     : record { nat64; TablePerms };
};

type PermRevoke = variant {
  Admin;
  ManageAcl;
  Migrate;
  AllTables : TablePerms;
  Table     : record { nat64; TablePerms };
};

type RequiredPerm = variant {
  Table     : TablePerms;
  Admin;
  ManageAcl;
  Migrate;
};

TablePerms is encoded as nat8 so the wire form is a single byte. The table identifier in per_table / Table is a TableFingerprint (nat64) — derived from the table name via xxh3.

IC-Specific Considerations

Re-exports: ic_dbms_api::prelude::* re-exports all types from wasm_dbms_api::prelude::* plus IC-specific additions. You do not need to import wasm_dbms_api directly.

CandidType requirement: All data types used in your table schemas must implement CandidType. The built-in types already do. If you define custom data types, they must also derive CandidType.

Principal storage: The Principal type is stored in binary format in stable memory (29 bytes max). It is serialized to/from its Candid principal representation when crossing canister boundaries.

Decimal precision: The Decimal type is serialized as text in Candid to preserve arbitrary precision. Frontends should parse the string representation rather than using floating-point conversion.

Errors Reference (IC)

Note: This is the IC-specific error handling reference. For the complete error hierarchy, all error variants, and their causes, see the generic errors reference.


Overview

When using ic-dbms through the ic-dbms-client crate, error handling has an additional layer compared to direct wasm-dbms usage. The IC’s inter-canister call model introduces network-level errors alongside database-level errors, resulting in the double Result pattern.


IcDbmsError Type Alias

IcDbmsError is a re-export of DbmsError from wasm-dbms-api, provided by ic-dbms-api for convenience:

#![allow(unused)]
fn main() {
use ic_dbms_api::prelude::IcDbmsError;

// IcDbmsError is the same as wasm_dbms_api::DbmsError
// It provides the full error hierarchy:
pub enum IcDbmsError {
    AccessDenied {
        table: Option<TableFingerprint>,
        required: RequiredPerm,
    },
    Memory(MemoryError),
    Migration(MigrationError),
    Query(QueryError),
    Table(TableError),
    Transaction(TransactionError),
    Sanitize(String),
    Validation(String),
}
}

You can use IcDbmsError or DbmsError interchangeably. The IcDbmsError alias is conventional in IC codebases.


AccessDenied

Granular ACL checks return DbmsError::AccessDenied { table, required } when the caller is missing a perm. required is a RequiredPerm enum:

VariantMeaning
Table(TablePerms)Per-table CRUD perm missing.
Adminadmin bypass missing.
ManageAclACL-management perm missing.
MigrateMigration perm missing.

table is Some(TableFingerprint) for table-scoped operations and None for manage_acl / migrate failures.

#![allow(unused)]
fn main() {
match res {
    Ok(()) => {}
    Err(IcDbmsError::AccessDenied { required: RequiredPerm::Table(p), .. }) => {
        eprintln!("missing table perms: {p:?}");
    }
    Err(IcDbmsError::AccessDenied { required: RequiredPerm::Migrate, .. }) => {
        eprintln!("not allowed to migrate");
    }
    Err(other) => return Err(other),
}
}

Double Result Pattern

Why Two Results?

Client operations return Result<Result<T, IcDbmsError>, CallError>:

Result<                          -- Outer: IC call result
    Result<T, IcDbmsError>,      -- Inner: Database operation result
    CallError                    -- Network/canister call error
>
  • Outer Result (CallError): The inter-canister call itself failed. This happens when:

    • The canister is unreachable or stopped
    • The canister ran out of cycles
    • The message was rejected (e.g., unauthorized caller)
    • Network timeout on agent calls
  • Inner Result (IcDbmsError): The call succeeded but the database operation failed. This happens when:

    • Primary key conflict
    • Foreign key constraint violation
    • Validation failure
    • Transaction not found
    • Any other database logic error

Using the ?? Operator

The simplest approach is to use ?? to unwrap both layers:

#![allow(unused)]
fn main() {
// Propagates both CallError and IcDbmsError
let users = client.select::<User>(User::table_name(), query, None).await??;
}

This requires your function to return an error type that both CallError and IcDbmsError can convert into (e.g., Box<dyn std::error::Error>, anyhow::Error, or a custom enum).

Explicit Error Handling

#![allow(unused)]
fn main() {
match client.insert::<User>(User::table_name(), user, None).await {
    Ok(Ok(())) => {
        // Success: call succeeded AND database operation succeeded
        println!("Insert successful");
    }
    Ok(Err(db_error)) => {
        // Call succeeded but database operation failed
        println!("Database error: {:?}", db_error);
    }
    Err(call_error) => {
        // Inter-canister call itself failed
        println!("Call failed: {:?}", call_error);
    }
}
}

Client Error Handling Examples

Basic Pattern

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

let result = client.insert::<User>(User::table_name(), user, None).await;

match result {
    Ok(Ok(())) => println!("Insert successful"),
    Ok(Err(e)) => println!("Database error: {:?}", e),
    Err(e) => println!("Call failed: {:?}", e),
}
}

Detailed Matching

#![allow(unused)]
fn main() {
match client.insert::<User>(User::table_name(), user, None).await {
    Ok(Ok(())) => {
        println!("Insert successful");
    }
    Ok(Err(db_error)) => {
        match db_error {
            IcDbmsError::Query(QueryError::PrimaryKeyConflict) => {
                println!("User already exists");
            }
            IcDbmsError::Query(QueryError::BrokenForeignKeyReference) => {
                println!("Referenced record doesn't exist");
            }
            IcDbmsError::Validation(msg) => {
                println!("Validation error: {}", msg);
            }
            _ => {
                println!("Database error: {:?}", db_error);
            }
        }
    }
    Err(call_error) => {
        println!("Failed to call canister: {:?}", call_error);
    }
}
}

Helper Function Pattern

#![allow(unused)]
fn main() {
fn handle_db_error(error: IcDbmsError) -> String {
    match error {
        IcDbmsError::Query(QueryError::PrimaryKeyConflict) =>
            "Record with this ID already exists".to_string(),
        IcDbmsError::Query(QueryError::BrokenForeignKeyReference) =>
            "Referenced record not found".to_string(),
        IcDbmsError::Query(QueryError::ForeignKeyConstraintViolation) =>
            "Cannot delete: record has dependencies".to_string(),
        IcDbmsError::Validation(msg) =>
            format!("Invalid data: {}", msg),
        _ =>
            format!("Unexpected error: {:?}", error),
    }
}

// Usage
let result = client.insert::<User>(User::table_name(), user, None).await;
match result {
    Ok(Ok(())) => Ok(()),
    Ok(Err(e)) => Err(handle_db_error(e)),
    Err(e) => Err(format!("Call failed: {:?}", e)),
}
}

Retry Pattern for Transient Errors

Network-level errors (outer Result) may be transient. Database errors (inner Result) are deterministic and should not be retried.

#![allow(unused)]
fn main() {
async fn insert_with_retry<T: Table>(
    client: &impl Client,
    table: &str,
    record: T::InsertRequest,
    max_retries: u32,
) -> Result<(), String> {
    for attempt in 0..max_retries {
        match client.insert::<T>(table, record.clone(), None).await {
            Ok(Ok(())) => return Ok(()),
            Ok(Err(e)) => {
                // Database errors are deterministic - don't retry
                return Err(format!("Database error: {:?}", e));
            }
            Err(call_err) => {
                // Call errors might be transient - retry
                if attempt < max_retries - 1 {
                    println!("Attempt {} failed, retrying...", attempt + 1);
                    continue;
                }
                return Err(format!(
                    "Call failed after {} attempts: {:?}",
                    max_retries, call_err
                ));
            }
        }
    }
    unreachable!()
}
}