ic_sql_migrate/lib.rs
1//! A lightweight database migration library for Internet Computer (ICP) canisters.
2//!
3//! This library provides automatic database schema management and version control
4//! for SQLite (via `ic-rusqlite`) and Turso databases in ICP canisters. Migrations
5//! are embedded at compile time and executed during canister initialization and upgrades.
6//!
7//! # Features
8//!
9//! **IMPORTANT**: You must enable exactly one database feature for this library to work:
10//! - **SQLite support** via `ic-rusqlite` (feature: `sqlite`)
11//! - **Turso support** for distributed SQLite (feature: `turso`)
12//!
13//! Additional capabilities:
14//! - **Automatic migration execution** on canister `init` and `post_upgrade`
15//! - **Compile-time migration embedding** via `include!()` macro
16//! - **Transaction-based execution** for atomicity
17//!
18//! The library has no default features. Attempting to use it without enabling
19//! either `sqlite` or `turso` will result in compilation errors when trying to
20//! access the database modules.
21//!
22//! # Quick Start for ICP Canisters
23//!
24//! ## 1. Prerequisites
25//! In addition to having the Rust toolchain setup and dfx, you need to install the `wasi2ic` tool that replaces WebAssembly System Interface (WASI) specific function calls with their corresponding polyfill implementations. This allows you to run Wasm binaries compiled for wasm32-wasi on the Internet Computer.
26//!
27//! ```bash
28//! cargo install wasi2ic
29//! ```
30//!
31//! ### Configure dfx.json
32//! You also need to configure your `dfx.json` to compile for the `wasm32-wasip1` target and use `wasi2ic` to process the binary:
33//!
34//! ```json
35//! {
36//! "canisters": {
37//! "your_canister": {
38//! "candid": "your_canister.did",
39//! "package": "your_canister",
40//! "type": "custom",
41//! "build": [
42//! "cargo build --target wasm32-wasip1 --release",
43//! "wasi2ic target/wasm32-wasip1/release/your_canister.wasm target/wasm32-wasip1/release/your_canister-wasi2ic.wasm"
44//! ],
45//! "wasm": "target/wasm32-wasip1/release/your_canister-wasi2ic.wasm"
46//! }
47//! }
48//! }
49//! ```
50//!
51//! ### For Turso
52//! No additional toolchain setup required beyond Rust and DFX.
53//!
54//! ## 2. Add to Cargo.toml
55//! ```toml
56//! [dependencies]
57//! ic-sql-migrate = { version = "0.0.4", features = ["sqlite"] } # or feature "turso"
58//! ic-rusqlite = { version = "0.4.2", features = ["precompiled"], default-features = false }
59//! # or turso = "0.1.4" for Turso
60//! ic-cdk = "0.18.7"
61//!
62//! [build-dependencies]
63//! ic-sql-migrate = "0.0.4"
64//! ```
65//!
66//! ## 3. Create build.rs
67//! ```no_run
68//! fn main() {
69//! ic_sql_migrate::list(Some("migrations")).unwrap();
70//! }
71//! ```
72//!
73//! ## 4. Use in canister
74//! ```ignore
75//! use ic_cdk::{init, post_upgrade, pre_upgrade};
76//! use ic_rusqlite::{close_connection, with_connection, Connection};
77//!
78//! static MIGRATIONS: &[ic_sql_migrate::Migration] = ic_sql_migrate::include!();
79//!
80//! fn run_migrations() {
81//! with_connection(|mut conn| {
82//! let conn: &mut Connection = &mut conn;
83//! ic_sql_migrate::sqlite::up(conn, MIGRATIONS).unwrap();
84//! });
85//! }
86//!
87//! #[init]
88//! fn init() {
89//! run_migrations();
90//! }
91//!
92//! #[pre_upgrade]
93//! fn pre_upgrade() {
94//! close_connection();
95//! }
96//!
97//! #[post_upgrade]
98//! fn post_upgrade() {
99//! run_migrations();
100//! }
101//! ```
102
103mod db;
104
105#[cfg(feature = "turso")]
106pub use crate::db::turso;
107
108#[cfg(feature = "sqlite")]
109pub use crate::db::sqlite;
110
111#[cfg(feature = "turso")]
112use ::turso as turso_crate;
113
114use thiserror::Error;
115
116/// Custom error type for migration operations.
117///
118/// This enum represents all possible errors that can occur during migration operations.
119/// The actual database error variant depends on the feature flag enabled (either `sqlite` or `turso`).
120#[derive(Debug, Error)]
121pub enum Error {
122 /// I/O operation failed during build-time migration discovery
123 #[error("IO error: {0}")]
124 Io(#[from] std::io::Error),
125
126 /// A specific migration failed to execute
127 ///
128 /// Contains the migration ID and the error message from the database
129 #[error("Migration '{id}' failed: {message}")]
130 MigrationFailed { id: String, message: String },
131
132 /// Environment variable was not found during build-time processing
133 #[error("Environment variable '{0}' not set")]
134 EnvVarNotFound(String),
135
136 /// Database error from the underlying database driver
137 #[error("Database error: {0}")]
138 Database(Box<dyn std::error::Error + Send + Sync>),
139}
140
141// IMPORTANT: Users must enable exactly one database feature: either 'sqlite' or 'turso'
142// The library can be compiled without features for publishing to crates.io,
143// but actual usage requires selecting a database backend. If no feature is selected,
144// the database modules will not be available and the library cannot be used.
145
146#[cfg(feature = "sqlite")]
147impl From<rusqlite::Error> for Error {
148 fn from(err: rusqlite::Error) -> Self {
149 Error::Database(Box::new(err))
150 }
151}
152
153#[cfg(feature = "turso")]
154impl From<turso_crate::Error> for Error {
155 fn from(err: turso_crate::Error) -> Self {
156 Error::Database(Box::new(err))
157 }
158}
159
160/// Type alias for `Result<T, Error>` used throughout the library.
161///
162/// This provides a convenient shorthand for functions that can return migration errors.
163pub type MigrateResult<T> = std::result::Result<T, Error>;
164
165/// Represents a single database migration with its unique identifier and SQL content.
166///
167/// Migrations are typically created at compile time by the `include!()` macro
168/// from SQL files in your migrations directory. Each migration consists of:
169/// - An identifier (usually the filename without extension)
170/// - The SQL statements to execute
171///
172/// # Example in ICP Canister
173/// ```
174/// use ic_sql_migrate::Migration;
175///
176/// // Typically included via the include!() macro:
177/// static MIGRATIONS: &[Migration] = &[
178/// Migration::new(
179/// "001_create_users",
180/// "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"
181/// ),
182/// Migration::new(
183/// "002_add_email",
184/// "ALTER TABLE users ADD COLUMN email TEXT;"
185/// ),
186/// ];
187/// ```
188#[derive(Debug, Clone)]
189pub struct Migration {
190 /// Unique identifier for the migration, typically derived from the filename.
191 /// This ID is stored in the `_migrations` table to track which migrations have been applied.
192 pub id: &'static str,
193 /// SQL statements to execute for this migration.
194 /// Can contain multiple statements separated by semicolons.
195 pub sql: &'static str,
196}
197
198impl Migration {
199 /// Creates a new migration with the given ID and SQL content.
200 ///
201 /// This is a `const fn`, allowing migrations to be created at compile time.
202 ///
203 /// # Arguments
204 /// * `id` - Unique identifier for the migration (must not contain whitespace or special characters)
205 /// * `sql` - SQL statements to execute (can be multiple statements separated by semicolons)
206 ///
207 /// # Example
208 /// ```
209 /// use ic_sql_migrate::Migration;
210 ///
211 /// // Static migrations for use in ICP canisters
212 /// static INIT_MIGRATION: Migration = Migration::new(
213 /// "001_init",
214 /// "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"
215 /// );
216 /// ```
217 pub const fn new(id: &'static str, sql: &'static str) -> Self {
218 Self { id, sql }
219 }
220}
221
222/// Includes all migration files discovered by the `list` function at compile time.
223///
224/// This macro expands to a static slice of `Migration` structs containing
225/// all SQL files found in the migrations directory. The migrations are ordered
226/// alphabetically by filename, so it's recommended to prefix them with numbers
227/// (e.g., `001_initial.sql`, `002_add_users.sql`).
228///
229/// # Prerequisites
230/// You must call `ic_sql_migrate::list()` in your `build.rs` file to generate
231/// the migration data that this macro includes.
232///
233/// # Example in ICP Canister
234/// ```ignore
235/// // In your canister lib.rs
236/// use ic_cdk::{init, post_upgrade};
237/// use ic_rusqlite::{with_connection, Connection};
238///
239/// static MIGRATIONS: &[ic_sql_migrate::Migration] = ic_sql_migrate::include!();
240///
241/// fn run_migrations() {
242/// with_connection(|mut conn| {
243/// let conn: &mut Connection = &mut conn;
244/// ic_sql_migrate::sqlite::up(conn, MIGRATIONS).unwrap();
245/// });
246/// }
247///
248/// #[init]
249/// fn init() {
250/// run_migrations();
251/// }
252///
253/// #[post_upgrade]
254/// fn post_upgrade() {
255/// run_migrations();
256/// }
257/// ```
258#[macro_export]
259macro_rules! include {
260 () => {
261 include!(concat!(env!("OUT_DIR"), "/migrations_gen.rs"))
262 };
263}
264
265/// Discovers and lists all SQL migration files for inclusion at compile time.
266///
267/// This function should be called in `build.rs` to generate code that embeds
268/// all migration files into the binary. It scans the specified directory for
269/// `.sql` files and generates Rust code to include them.
270///
271/// The function will:
272/// 1. Look for SQL files in the specified directory (relative to `Cargo.toml`)
273/// 2. Sort them alphabetically by filename
274/// 3. Generate code that includes their content at compile time
275/// 4. Set up cargo to rebuild when migration files change
276///
277/// # Arguments
278/// * `migrations_dir_name` - Optional custom directory name (defaults to "migrations")
279///
280/// # Example in build.rs
281/// ```no_run
282/// // In your canister's build.rs file
283/// fn main() {
284/// // Use default "migrations" directory
285/// ic_sql_migrate::list(None).unwrap();
286///
287/// // Or specify a custom directory relative to Cargo.toml
288/// ic_sql_migrate::list(Some("migrations")).unwrap();
289/// }
290/// ```
291///
292/// # File Naming Convention
293/// Migration files should be named with a sortable prefix to ensure correct execution order:
294/// - `001_initial_schema.sql`
295/// - `002_add_users_table.sql`
296/// - `003_add_indexes.sql`
297///
298/// # Errors
299/// Returns an I/O error if:
300/// - The output directory (`OUT_DIR`) cannot be written to
301/// - File system operations fail
302/// - Environment variables `CARGO_MANIFEST_DIR` or `OUT_DIR` are not set
303pub fn list(migrations_dir_name: Option<&str>) -> std::io::Result<()> {
304 use std::env;
305 use std::fs;
306 use std::path::Path;
307
308 let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|_| {
309 std::io::Error::new(std::io::ErrorKind::NotFound, "CARGO_MANIFEST_DIR not set")
310 })?;
311
312 let dir_name = migrations_dir_name.unwrap_or("migrations");
313 let migrations_dir = Path::new(&manifest_dir).join(dir_name);
314
315 // Ensure cargo rebuilds when migrations change
316 println!("cargo:rerun-if-changed={}", migrations_dir.display());
317
318 // Generate the output file path
319 let out_dir = env::var("OUT_DIR")
320 .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "OUT_DIR not set"))?;
321 let dest_path = Path::new(&out_dir).join("migrations_gen.rs");
322
323 // If migrations directory doesn't exist, create empty migrations array
324 if !migrations_dir.exists() {
325 fs::write(dest_path, "&[]")?;
326 return Ok(());
327 }
328
329 // Collect all SQL files
330 let migration_files = collect_migration_files(&migrations_dir)?;
331
332 // Generate and write the Rust code
333 let generated_code = generate_migrations_code(&migration_files);
334 fs::write(dest_path, generated_code)?;
335
336 Ok(())
337}
338
339/// Collects all SQL migration files from the specified directory.
340///
341/// Returns a sorted list of (migration_id, file_path) tuples.
342fn collect_migration_files(
343 migrations_dir: &std::path::Path,
344) -> std::io::Result<Vec<(String, String)>> {
345 use std::fs;
346
347 let mut migration_files = Vec::new();
348
349 let entries = fs::read_dir(migrations_dir)?;
350 for entry in entries {
351 let entry = entry?;
352 let path = entry.path();
353
354 // Only process .sql files
355 if path.extension().and_then(|s| s.to_str()) != Some("sql") {
356 continue;
357 }
358
359 if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
360 let absolute_path = path.to_string_lossy().to_string();
361 migration_files.push((file_stem.to_string(), absolute_path));
362
363 // Ensure cargo rebuilds when this specific file changes
364 println!("cargo:rerun-if-changed={}", path.display());
365 }
366 }
367
368 // Sort migration files by name to ensure consistent ordering
369 migration_files.sort_by(|a, b| a.0.cmp(&b.0));
370
371 Ok(migration_files)
372}
373
374/// Generates Rust code for including migration files.
375///
376/// Creates a static array initialization with all migration files.
377fn generate_migrations_code(migration_files: &[(String, String)]) -> String {
378 let mut code = String::from("&[\n");
379
380 for (migration_id, file_path) in migration_files {
381 code.push_str(&format!(
382 " ic_sql_migrate::Migration::new(\"{migration_id}\", include_str!(\"{file_path}\")),\n"
383 ));
384 }
385
386 code.push_str("]\n");
387 code
388}