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_migrations!()` 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//! ic_sql_migrate::Builder::new().build().unwrap();
69//! ```
70//!
71//! ## 4. Use in canister
72//! ```ignore
73//! use ic_cdk::{init, post_upgrade, pre_upgrade};
74//! use ic_rusqlite::{close_connection, with_connection, Connection};
75//!
76//! static MIGRATIONS: &[ic_sql_migrate::Migration] = ic_sql_migrate::include_migrations!();
77//!
78//! fn run_migrations() {
79//!     with_connection(|mut conn| {
80//!         let conn: &mut Connection = &mut conn;
81//!         ic_sql_migrate::sqlite::migrate(conn, MIGRATIONS).unwrap();
82//!     });
83//! }
84//!
85//! #[init]
86//! fn init() {
87//!     run_migrations();
88//! }
89//!
90//! #[pre_upgrade]
91//! fn pre_upgrade() {
92//!     close_connection();
93//! }
94//!
95//! #[post_upgrade]
96//! fn post_upgrade() {
97//!     run_migrations();
98//! }
99//! ```
100
101mod db;
102
103#[cfg(feature = "turso")]
104pub use crate::db::turso;
105
106#[cfg(feature = "sqlite")]
107pub use crate::db::sqlite;
108
109#[cfg(feature = "turso")]
110use ::turso as turso_crate;
111
112use thiserror::Error;
113
114/// Custom error type for migration operations.
115///
116/// This enum represents all possible errors that can occur during migration operations.
117/// The actual database error variant depends on the feature flag enabled (either `sqlite` or `turso`).
118#[derive(Debug, Error)]
119pub enum Error {
120    /// I/O operation failed during build-time migration discovery
121    #[error("IO error: {0}")]
122    Io(#[from] std::io::Error),
123
124    /// A specific migration failed to execute
125    ///
126    /// Contains the migration ID and the error message from the database
127    #[error("Migration '{id}' failed: {message}")]
128    MigrationFailed { id: String, message: String },
129
130    /// Environment variable was not found during build-time processing
131    #[error("Environment variable '{0}' not set")]
132    EnvVarNotFound(String),
133
134    /// Database error from the underlying database driver
135    #[error("Database error: {0}")]
136    Database(Box<dyn std::error::Error + Send + Sync>),
137}
138
139// IMPORTANT: Users must enable exactly one database feature: either 'sqlite' or 'turso'
140// The library can be compiled without features for publishing to crates.io,
141// but actual usage requires selecting a database backend. If no feature is selected,
142// the database modules will not be available and the library cannot be used.
143
144#[cfg(feature = "sqlite")]
145impl From<rusqlite::Error> for Error {
146    fn from(err: rusqlite::Error) -> Self {
147        Error::Database(Box::new(err))
148    }
149}
150
151#[cfg(feature = "turso")]
152impl From<turso_crate::Error> for Error {
153    fn from(err: turso_crate::Error) -> Self {
154        Error::Database(Box::new(err))
155    }
156}
157
158/// Type alias for `Result<T, Error>` used throughout the library.
159///
160/// This provides a convenient shorthand for functions that can return migration errors.
161pub type MigrateResult<T> = std::result::Result<T, Error>;
162
163/// Type alias for seed functions that take a SQLite connection.
164///
165/// Seed functions are called after migrations to populate initial data.
166#[cfg(feature = "sqlite")]
167pub type SqliteSeedFn = fn(&rusqlite::Connection) -> MigrateResult<()>;
168
169/// Type alias for async seed functions that take a Turso connection.
170///
171/// Seed functions are called after migrations to populate initial data.
172#[cfg(feature = "turso")]
173pub type TursoSeedFn =
174    fn(
175        &turso_crate::Connection,
176    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = MigrateResult<()>> + Send>>;
177
178/// Represents a single database seed with its unique identifier and execution function.
179///
180/// Seeds are typically created at compile time and executed after migrations
181/// to populate initial or test data using Rust code rather than SQL.
182///
183/// # Example
184/// ```
185/// use ic_sql_migrate::Seed;
186///
187/// fn seed_users(conn: &rusqlite::Connection) -> ic_sql_migrate::MigrateResult<()> {
188///     conn.execute("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')", [])?;
189///     Ok(())
190/// }
191///
192/// static SEEDS: &[Seed] = &[
193///     Seed::new("001_initial_users", seed_users),
194/// ];
195/// ```
196#[cfg(feature = "sqlite")]
197#[derive(Clone, Copy)]
198pub struct Seed {
199    pub id: &'static str,
200    pub seed_fn: SqliteSeedFn,
201}
202
203#[cfg(feature = "sqlite")]
204impl Seed {
205    pub const fn new(id: &'static str, seed_fn: SqliteSeedFn) -> Self {
206        Self { id, seed_fn }
207    }
208}
209
210#[cfg(feature = "turso")]
211#[derive(Clone, Copy)]
212pub struct Seed {
213    pub id: &'static str,
214    pub seed_fn: TursoSeedFn,
215}
216
217#[cfg(feature = "turso")]
218impl Seed {
219    pub const fn new(id: &'static str, seed_fn: TursoSeedFn) -> Self {
220        Self { id, seed_fn }
221    }
222}
223
224/// Represents a single database migration with its unique identifier and SQL content.
225///
226/// Migrations are typically created at compile time by the `include_migrations!()` macro
227/// from SQL files in your migrations directory. Each migration consists of:
228/// - An identifier (usually the filename without extension)
229/// - The SQL statements to execute
230///
231/// # Example in ICP Canister
232/// ```
233/// use ic_sql_migrate::Migration;
234///
235/// // Typically included via the include_migrations!() macro:
236/// static MIGRATIONS: &[Migration] = &[
237///     Migration::new(
238///         "001_create_users",
239///         "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"
240///     ),
241///     Migration::new(
242///         "002_add_email",
243///         "ALTER TABLE users ADD COLUMN email TEXT;"
244///     ),
245/// ];
246/// ```
247#[derive(Debug, Clone)]
248pub struct Migration {
249    /// Unique identifier for the migration, typically derived from the filename.
250    /// This ID is stored in the `_migrations` table to track which migrations have been applied.
251    pub id: &'static str,
252    /// SQL statements to execute for this migration.
253    /// Can contain multiple statements separated by semicolons.
254    pub sql: &'static str,
255}
256
257impl Migration {
258    /// Creates a new migration with the given ID and SQL content.
259    ///
260    /// This is a `const fn`, allowing migrations to be created at compile time.
261    ///
262    /// # Arguments
263    /// * `id` - Unique identifier for the migration (must not contain whitespace or special characters)
264    /// * `sql` - SQL statements to execute (can be multiple statements separated by semicolons)
265    ///
266    /// # Example
267    /// ```
268    /// use ic_sql_migrate::Migration;
269    ///
270    /// // Static migrations for use in ICP canisters
271    /// static INIT_MIGRATION: Migration = Migration::new(
272    ///     "001_init",
273    ///     "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"
274    /// );
275    /// ```
276    pub const fn new(id: &'static str, sql: &'static str) -> Self {
277        Self { id, sql }
278    }
279}
280
281/// Includes all migration files discovered by the Builder at compile time.
282///
283/// This macro expands to a static slice of `Migration` structs containing
284/// all SQL files found in the migrations directory. The migrations are ordered
285/// alphabetically by filename, so it's recommended to prefix them with numbers
286/// (e.g., `001_initial.sql`, `002_add_users.sql`).
287///
288/// # Prerequisites
289/// You must call `ic_sql_migrate::Builder::new().build()` in your `build.rs` file to generate
290/// the migration data that this macro includes.
291///
292/// # Example in ICP Canister
293/// ```ignore
294/// // In your canister lib.rs
295/// use ic_cdk::{init, post_upgrade};
296/// use ic_rusqlite::{with_connection, Connection};
297///
298/// static MIGRATIONS: &[ic_sql_migrate::Migration] = ic_sql_migrate::include_migrations!();
299///
300/// fn run_migrations() {
301///     with_connection(|mut conn| {
302///         let conn: &mut Connection = &mut conn;
303///         ic_sql_migrate::sqlite::migrate(conn, MIGRATIONS).unwrap();
304///     });
305/// }
306///
307/// #[init]
308/// fn init() {
309///     run_migrations();
310/// }
311///
312/// #[post_upgrade]
313/// fn post_upgrade() {
314///     run_migrations();
315/// }
316/// ```
317#[macro_export]
318macro_rules! include_migrations {
319    () => {
320        include!(concat!(env!("OUT_DIR"), "/migrations_gen.rs"))
321    };
322}
323
324/// Builder for configuring migration and seed discovery at compile time.
325///
326/// This builder allows you to customize the directories where migrations and seeds
327/// are located. By default, it looks for migrations in `migrations/` and seeds in `src/seeds/`.
328///
329/// # Example in build.rs
330/// ```no_run
331/// // Use defaults (migrations/ and src/seeds/)
332/// // If either directory doesn't exist, it will be skipped automatically
333/// ic_sql_migrate::Builder::new().build().unwrap();
334///
335/// // Custom directories
336/// ic_sql_migrate::Builder::new()
337///     .with_migrations_dir("db/migrations")
338///     .with_seeds_dir("src/db/seeds")
339///     .build()
340///     .unwrap();
341/// ```
342pub struct Builder {
343    migrations_dir: String,
344    seeds_dir: String,
345}
346
347impl Builder {
348    /// Creates a new builder with default settings.
349    ///
350    /// Defaults:
351    /// - Migrations directory: `migrations/`
352    /// - Seeds directory: `src/seeds/`
353    pub fn new() -> Self {
354        Self {
355            migrations_dir: "migrations".to_string(),
356            seeds_dir: "src/seeds".to_string(),
357        }
358    }
359
360    /// Sets the directory where migration SQL files are located.
361    ///
362    /// # Arguments
363    /// * `dir` - Path relative to `Cargo.toml`
364    pub fn with_migrations_dir(mut self, dir: impl Into<String>) -> Self {
365        self.migrations_dir = dir.into();
366        self
367    }
368
369    /// Sets the directory where seed Rust files are located.
370    ///
371    /// # Arguments
372    /// * `dir` - Path relative to `Cargo.toml`
373    pub fn with_seeds_dir(mut self, dir: impl Into<String>) -> Self {
374        self.seeds_dir = dir.into();
375        self
376    }
377
378    /// Executes the builder, discovering and generating code for migrations and seeds.
379    ///
380    /// This method automatically handles missing directories by generating empty arrays.
381    /// You don't need to specify whether directories exist or not.
382    ///
383    /// # Errors
384    /// Returns an I/O error if file system operations fail or required environment
385    /// variables are not set.
386    pub fn build(self) -> std::io::Result<()> {
387        use std::env;
388        use std::fs;
389        use std::path::Path;
390
391        let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|_| {
392            std::io::Error::new(std::io::ErrorKind::NotFound, "CARGO_MANIFEST_DIR not set")
393        })?;
394
395        let out_dir = env::var("OUT_DIR")
396            .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "OUT_DIR not set"))?;
397
398        // Process migrations
399        let migrations_dir = Path::new(&manifest_dir).join(&self.migrations_dir);
400        println!("cargo:rerun-if-changed={}", migrations_dir.display());
401
402        let migrations_dest = Path::new(&out_dir).join("migrations_gen.rs");
403
404        if !migrations_dir.exists() {
405            fs::write(migrations_dest, "&[]")?;
406        } else {
407            let migration_files = collect_migration_files(&migrations_dir)?;
408            let generated_code = generate_migrations_code(&migration_files);
409            fs::write(migrations_dest, generated_code)?;
410        }
411
412        // Process seeds - generate mod.rs in the seeds directory
413        let seeds_dir = Path::new(&manifest_dir).join(&self.seeds_dir);
414        println!("cargo:rerun-if-changed={}", seeds_dir.display());
415
416        if seeds_dir.exists() {
417            let seed_files = collect_seed_files(&seeds_dir)?;
418            if !seed_files.is_empty() {
419                let generated_code = generate_seeds_code(&seed_files);
420                let mod_file = seeds_dir.join("mod.rs");
421                fs::write(mod_file, generated_code)?;
422            }
423        }
424
425        Ok(())
426    }
427}
428
429impl Default for Builder {
430    fn default() -> Self {
431        Self::new()
432    }
433}
434
435/// Collects all SQL migration files from the specified directory.
436///
437/// Returns a sorted list of (migration_id, file_path) tuples.
438fn collect_migration_files(
439    migrations_dir: &std::path::Path,
440) -> std::io::Result<Vec<(String, String)>> {
441    use std::fs;
442
443    let mut migration_files = Vec::new();
444
445    let entries = fs::read_dir(migrations_dir)?;
446    for entry in entries {
447        let entry = entry?;
448        let path = entry.path();
449
450        // Only process .sql files
451        if path.extension().and_then(|s| s.to_str()) != Some("sql") {
452            continue;
453        }
454
455        if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
456            let absolute_path = path.to_string_lossy().to_string();
457            migration_files.push((file_stem.to_string(), absolute_path));
458
459            // Ensure cargo rebuilds when this specific file changes
460            println!("cargo:rerun-if-changed={}", path.display());
461        }
462    }
463
464    // Sort migration files by name to ensure consistent ordering
465    migration_files.sort_by(|a, b| a.0.cmp(&b.0));
466
467    Ok(migration_files)
468}
469
470/// Generates Rust code for including migration files.
471///
472/// Creates a static array initialization with all migration files.
473fn generate_migrations_code(migration_files: &[(String, String)]) -> String {
474    let mut code = String::from("&[\n");
475
476    for (migration_id, file_path) in migration_files {
477        code.push_str(&format!(
478            "    ic_sql_migrate::Migration::new(\"{migration_id}\", include_str!(\"{file_path}\")),\n"
479        ));
480    }
481
482    code.push_str("]\n");
483    code
484}
485
486/// Collects all Rust seed files from the specified directory.
487///
488/// Returns a sorted list of (seed_id, module_path) tuples.
489/// Excludes mod.rs as it's the module declaration file.
490fn collect_seed_files(seeds_dir: &std::path::Path) -> std::io::Result<Vec<(String, String)>> {
491    use std::fs;
492
493    let mut seed_files = Vec::new();
494
495    let entries = fs::read_dir(seeds_dir)?;
496    for entry in entries {
497        let entry = entry?;
498        let path = entry.path();
499
500        if path.extension().and_then(|s| s.to_str()) != Some("rs") {
501            continue;
502        }
503
504        if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
505            // Skip mod.rs as it's the generated module file
506            if file_stem == "mod" {
507                continue;
508            }
509
510            let absolute_path = path.to_string_lossy().to_string();
511            seed_files.push((file_stem.to_string(), absolute_path));
512
513            println!("cargo:rerun-if-changed={}", path.display());
514        }
515    }
516
517    seed_files.sort_by(|a, b| a.0.cmp(&b.0));
518
519    Ok(seed_files)
520}
521
522/// Generates a mod.rs file for the seeds module.
523///
524/// Creates a module file that:
525/// 1. Declares all seed submodules in alphabetical order
526/// 2. Exports a SEEDS constant with all seed functions in order
527///
528/// This function is feature-agnostic and generates generic code.
529/// The actual type checking happens at compile time when the user's
530/// crate is built with the appropriate feature.
531fn generate_seeds_code(seed_files: &[(String, String)]) -> String {
532    let mut code = String::new();
533
534    code.push_str("// This file is auto-generated by ic-sql-migrate\n");
535    code.push_str("// Do not edit manually\n\n");
536
537    // Declare all submodules
538    for (seed_id, _) in seed_files {
539        code.push_str(&format!("pub mod {seed_id};\n"));
540    }
541
542    code.push('\n');
543    code.push_str("use ic_sql_migrate::Seed;\n\n");
544
545    // Create the SEEDS array
546    code.push_str("pub static SEEDS: &[Seed] = &[\n");
547    for (seed_id, _) in seed_files {
548        code.push_str(&format!("    Seed::new(\"{seed_id}\", {seed_id}::seed),\n"));
549    }
550    code.push_str("];\n");
551
552    code
553}