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}