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:
| Crate | Description | Depends On |
|---|---|---|
| ic-dbms-api | Shared types, re-exports wasm-dbms-api types with IC additions. Provides IcDbmsError type alias and IC-compatible type wrappers. | wasm-dbms-api |
| ic-dbms-canister | Core 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-macros | Procedural macros: #[derive(DatabaseSchema)] (IC variant, uses IC crate paths) and #[derive(DbmsCanister)] for generating complete canister APIs. | wasm-dbms-macros |
| ic-dbms-client | Client 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
- 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,
}
}
- 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!();
}
- 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
- Get Started - Set up and deploy your first IC database canister
- CRUD Operations - Insert, select, update, delete via the IC client
- Access Control - ACL management with IC principals
- Client API - All client types and usage patterns
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.
- Get Started with IC-DBMS (IC)
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-unknowntarget:rustup target add wasm32-unknown-unknown- dfx (Internet Computer SDK)
ic-wasm:cargo install ic-wasmcandid-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-apire-exports types fromwasm-dbms-api, souse 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 Type | Purpose |
|---|---|
UserRecord | Full record returned from queries |
UserInsertRequest | Request type for inserting records |
UserUpdateRequest | Request type for updating records |
UserForeignFetcher | Internal 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_principalscan 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:
- CRUD Operations (IC) - Detailed guide on all database operations via the IC client
- Access Control - Managing the ACL
- Client API - All client types and usage patterns
- Schema Definition (IC) - IC-specific schema reference (DbmsCanister macro, Candid API)
- Data Types (IC) - IC-specific data types (Principal, Candid mappings)
- Errors (IC) - IC-specific error handling (double-Result pattern)
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.
| Operation | Description | Returns |
|---|---|---|
| Insert | Add a new record to a table | Result<()> |
| Select | Query records from a table | Result<Vec<Record>> |
| Update | Modify existing records | Result<u64> (affected rows) |
| Delete | Remove records from a table | Result<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:
| Behavior | Description |
|---|---|
Restrict | Fail if any foreign keys reference this record |
Cascade | Delete 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:
| Error | Cause | Operation |
|---|---|---|
PrimaryKeyConflict | Record with same primary key exists | Insert |
ForeignKeyConstraintViolation | Referenced record doesn’t exist, or delete restricted | Insert, Update, Delete |
BrokenForeignKeyReference | Foreign key points to non-existent record | Insert, Update |
UnknownColumn | Invalid column name in filter or select | Select, Update, Delete |
MissingNonNullableField | Required field not provided | Insert, Update |
RecordNotFound | No record matches the criteria | Update, Delete |
TransactionNotFound | Invalid transaction ID | All |
InvalidQuery | Malformed 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:
| Field | Type | Meaning |
|---|---|---|
admin | bool | Bypass all per-table checks. Does NOT imply other ops. |
manage_acl | bool | Grant/revoke perms; add/remove identities. |
migrate | bool | Run migrate / pending_migrations / has_drift. |
all_tables | TablePerms | Per-op bits applied to every table. |
per_table | Vec<(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_principals | Result |
|---|---|
None | Deployer 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
| Endpoint | Required perm | Effect |
|---|---|---|
grant_admin | manage_acl | Set admin on target. |
revoke_admin | manage_acl | Clear admin on target. |
grant_manage_acl | manage_acl | Set manage_acl on target. |
revoke_manage_acl | manage_acl | Clear manage_acl on target. |
grant_migrate | manage_acl | Set migrate on target. |
revoke_migrate | manage_acl | Clear migrate on target. |
Table perms
| Endpoint | Required perm | Effect |
|---|---|---|
grant_all_tables_perms | manage_acl | OR perms into all_tables. |
revoke_all_tables_perms | manage_acl | Mask perms out of all_tables. |
grant_table_perms | manage_acl | OR perms into per_table[table]. |
revoke_table_perms | manage_acl | Mask perms out of per_table[table]. |
Identity lifecycle
| Endpoint | Required perm | Effect |
|---|---|---|
remove_identity | manage_acl | Drop the identity entirely. |
list_identities | manage_acl | List 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 kind | Required perm |
|---|---|
select_* / aggregate_* / select | TablePerms::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
| Endpoint | Required perm |
|---|---|
has_drift | migrate |
pending_migrations | migrate |
migrate | migrate |
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.
- Client API (IC)
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:
| Client | Use Case | Feature Flag |
|---|---|---|
IcDbmsCanisterClient | Inter-canister calls (inside IC canisters) | Default |
IcDbmsAgentClient | External applications (frontend, backend, CLI) | ic-agent |
IcDbmsPocketIcClient | Integration tests with PocketIC | pocket-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], theMigratetrait,MigrationOpsemantics) 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:
| Endpoint | Kind | Purpose |
|---|---|---|
has_drift | query | O(1) once cached; true iff a migration is needed. |
pending_migrations | query | Returns the planned Vec<MigrationOp> without applying. |
migrate | update | Plans, 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:
- Edit the schema. Modify the
#[derive(Table)]structs and add#[default]/#[renamed_from]/#[migrate]as needed. - Build the canister.
just build_allcompiles towasm32-unknown-unknown, shrinks the WASM, and extracts the new.did. - Deploy via
dfx canister install --mode upgrade. Stable memory carries over untouched. - Inspect drift. Call
has_driftfromdfx, an admin tool, or aClient. Skip the rest iffalse. - Plan. Call
pending_migrationsand review the returned ops. Look in particular for unintendedDropTable/DropColumnops, which usually signal a typo in#[table = "..."]or a missing#[renamed_from]. - Apply. Call
migrate(record { allow_destructive = false }). If the plan contains a deliberate destructive op, setallow_destructive = trueonly after the review step. - Verify. Re-run
has_drift; expectfalse. 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_columntraps 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:
- Upgrade the canister. The new WASM boots in drift state.
- Run a one-shot script (CLI / admin canister /
dfx) that callspending_migrations, prints the plan, and waits for confirmation. - 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:
| Variant | Meaning | Caller action |
|---|---|---|
SchemaDrift | CRUD called while drift is set. | Call migrate. |
IncompatibleType | Column type changed without a widening or transform. | Add a transform_column arm or a release that widens via an intermediate type. |
DefaultMissing | New non-nullable column with no #[default] or default_value. | Add the default; redeploy. |
ConstraintViolation | Tightening rejected an existing row. | Backfill the offending rows in a prior release. |
DestructiveOpDenied | Plan contained DropTable / DropColumn and policy disallowed it. | Re-run with allow_destructive: true after operator review. |
TransformAborted | User transform_column returned Err. | Fix the transform; redeploy. |
WideningIncompatible | WidenColumn outside the widening whitelist with no transform_column handler. | Provide a transform_column impl or split the change across multiple releases. |
TransformReturnedNone | Migrate::transform_column returned Ok(None) for a column that needs one. | Implement the transform branch. |
ForeignKeyViolation | Add-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
DbmsCanistermacro additionally emits thehas_drift,pending_migrations, andmigrateCandid 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 / Attribute | Required for IC | Purpose |
|---|---|---|
Table | Yes | Generates table schema and related types |
CandidType | Yes (IC-specific) | Enables Candid serialization for the table struct |
Deserialize | Yes (IC-specific) | Enables deserialization from Candid wire format |
#[candid] | Yes (IC-specific) | Adds Candid/Serde derives to generated Record, InsertRequest, UpdateRequest types |
Clone | Yes | Required by the macro system |
Debug | Recommended | Useful for debugging |
PartialEq, Eq | Recommended | Useful 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 dispatchesselect,insert,update,delete, andselect_rawcalls to the correct table by name - A
register_tablesassociated 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", ...)]
StructNameis the Rust struct name (must be in scope viause)"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 natis the optional transaction IDselectandaggregatemethods arequerycalls (no state changes, no cycles consumed)- All other methods are
updatecalls
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_driftisO(1)once the per-context drift flag is cached. CRUD endpoints early-returnIcDbmsError::Migration(MigrationError::SchemaDrift)while drift is set; ACL and migration endpoints bypass the check.pending_migrationsalways recomputes the diff. Safe to call during drift.migrateplans, validates againstMigrationPolicy, 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:
dfxdeployment- 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 Type | Rust Type | Candid Type | Notes |
|---|---|---|---|
Uint8 | u8 | nat8 | |
Uint16 | u16 | nat16 | |
Uint32 | u32 | nat32 | |
Uint64 | u64 | nat64 | |
Int8 | i8 | int8 | |
Int16 | i16 | int16 | |
Int32 | i32 | int32 | |
Int64 | i64 | int64 | |
Decimal | rust_decimal::Decimal | text | Serialized as string for precision |
Text | String | text | |
Boolean | bool | bool | |
Date | chrono::NaiveDate | record { year; month; day } | Structured record |
DateTime | chrono::DateTime<Utc> | int64 | Unix timestamp |
Blob | Vec<u8> | blob | |
Principal | candid::Principal | principal | IC-specific |
Uuid | uuid::Uuid | text | String representation |
Json | serde_json::Value | text | Serialized JSON string |
Nullable<T> | Option<T> | opt T | Candid 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:
| Variant | Meaning |
|---|---|
Table(TablePerms) | Per-table CRUD perm missing. |
Admin | admin bypass missing. |
ManageAcl | ACL-management perm missing. |
Migrate | Migration 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!()
}
}