Skip to main content

kasl/db/
migrations.rs

1//! Database schema migration management and versioning system.
2//!
3//! Provides a comprehensive migration framework for evolving the database schema
4//! over time while maintaining data integrity and consistency.
5//!
6//! ## Features
7//!
8//! - **Version Tracking**: Maintains precise records of applied migrations
9//! - **Automatic Application**: Runs pending migrations during database initialization
10//! - **Transaction Safety**: All migrations run within database transactions
11//! - **Rollback Support**: Development-time rollback capabilities (debug builds only)
12//! - **History Tracking**: Complete audit trail of schema changes
13//!
14//! ## Usage
15//!
16//! ```rust
17//! use kasl::db::migrations::{init_with_migrations, get_db_version};
18//! use rusqlite::Connection;
19//!
20//! let mut conn = Connection::open("kasl.db")?;
21//! init_with_migrations(&mut conn)?;
22//! let version = get_db_version(&conn)?;
23//! ```
24
25use crate::libs::messages::Message;
26use crate::{msg_debug, msg_error, msg_info, msg_success};
27use anyhow::Result;
28use rusqlite::{params, Connection, Transaction};
29
30/// SQL schema for the migrations tracking table.
31///
32/// This table maintains a complete record of all applied migrations,
33/// enabling version tracking and providing an audit trail of schema changes.
34/// Each migration is recorded with its version, name, and application timestamp.
35const MIGRATIONS_TABLE: &str = "
36CREATE TABLE IF NOT EXISTS migrations (
37    id INTEGER PRIMARY KEY,
38    version INTEGER NOT NULL UNIQUE,
39    name TEXT NOT NULL,
40    applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
41)";
42
43/// Represents a single database migration with execution logic.
44///
45/// Each migration contains the information needed to apply a specific
46/// schema change, including version tracking and the transformation function.
47/// Migrations are designed to be immutable and deterministic.
48#[derive(Debug, Clone)]
49struct Migration {
50    /// Unique version number for ordering and tracking
51    version: u32,
52    /// Human-readable name describing the migration's purpose
53    name: &'static str,
54    /// Function that applies the schema changes within a transaction
55    up: fn(&Transaction) -> Result<()>,
56}
57
58/// Central migration system manager that orchestrates schema evolution.
59///
60/// The `MigrationManager` maintains the complete registry of available migrations
61/// and provides the logic for applying them in the correct order. It ensures
62/// that migrations are applied atomically and tracks their completion status.
63///
64/// ## Architecture
65///
66/// - **Migration Registry**: Stores all available migrations in version order
67/// - **Version Control**: Tracks current schema version and pending changes  
68/// - **Transaction Management**: Ensures each migration is atomic
69/// - **Error Recovery**: Provides rollback on migration failures
70///
71/// ## Thread Safety
72///
73/// The migration manager is designed for single-threaded use during application
74/// startup. Multiple concurrent migration attempts should be avoided.
75pub struct MigrationManager {
76    /// Ordered list of all available migrations
77    ///
78    /// Migrations are stored in version order to ensure correct application
79    /// sequence. Each migration builds upon the schema state created by
80    /// its predecessors.
81    migrations: Vec<Migration>,
82}
83
84impl MigrationManager {
85    /// Creates a new migration manager with all registered migrations.
86    ///
87    /// This constructor automatically registers all available migrations
88    /// in the correct order. The registration process is deterministic
89    /// and ensures consistent schema evolution across all environments.
90    ///
91    /// # Returns
92    ///
93    /// Returns a fully initialized migration manager ready to apply
94    /// pending schema changes.
95    ///
96    /// # Example
97    ///
98    /// ```rust
99    /// use kasl::db::migrations::MigrationManager;
100    ///
101    /// let manager = MigrationManager::new();
102    /// // Manager is ready to apply migrations
103    /// ```
104    pub fn new() -> Self {
105        let mut manager = Self { migrations: Vec::new() };
106
107        // Register all migrations in chronological order
108        // Each registration adds a migration to the internal registry
109        manager.register_migrations();
110        manager
111    }
112
113    /// Registers all database migrations in chronological order.
114    ///
115    /// This method defines the complete schema evolution history by registering
116    /// each migration version with its transformation logic. Migrations must
117    /// be registered in sequential version order to ensure correct application.
118    ///
119    /// ## Migration Design Principles
120    ///
121    /// - **Incremental**: Each migration makes small, focused changes
122    /// - **Idempotent**: Migrations can be safely re-run if needed
123    /// - **Forward-Only**: No backward compatibility requirements
124    /// - **Atomic**: Each migration succeeds or fails completely
125    fn register_migrations(&mut self) {
126        // Initial schema - version 0 is implicit (empty database)
127        // Base tables are created by individual modules as needed
128
129        // Version 1: Base tables and performance indices
130        // Creates fundamental tables and adds indices for better performance
131        self.add_migration(1, "create_tables_and_indices", |tx| {
132            // First, create base tables that individual modules depend on
133            // This ensures tables exist before any indices are created
134
135            // Create tasks table
136            tx.execute(
137                "CREATE TABLE IF NOT EXISTS tasks (
138        id INTEGER NOT NULL PRIMARY KEY,
139        task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
140        timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
141        name TEXT NOT NULL,
142        comment TEXT,
143        completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
144        excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
145    )",
146                [],
147            )?;
148
149            // Create pauses table
150            tx.execute(
151                "CREATE TABLE IF NOT EXISTS pauses (
152        id INTEGER NOT NULL PRIMARY KEY,
153        start TIMESTAMP NOT NULL,
154        end TIMESTAMP,
155        duration INTEGER
156    )",
157                [],
158            )?;
159
160            // Create workdays table
161            tx.execute(
162                "CREATE TABLE IF NOT EXISTS workdays (
163        id INTEGER PRIMARY KEY,
164        date DATE NOT NULL UNIQUE,
165        start TIMESTAMP NOT NULL,
166        end TIMESTAMP
167    )",
168                [],
169            )?;
170
171            // Now create indices for the tables we just created
172
173            // Index tasks by timestamp for chronological queries
174            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_timestamp ON tasks(timestamp)", [])?;
175            // Index tasks by parent task relationship
176            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id)", [])?;
177            // Index pauses by start time for temporal queries
178            tx.execute("CREATE INDEX IF NOT EXISTS idx_pauses_start ON pauses(start)", [])?;
179            // Index workdays by date for daily/monthly reporting
180            tx.execute("CREATE INDEX IF NOT EXISTS idx_workdays_date ON workdays(date)", [])?;
181
182            Ok(())
183        });
184
185        // Version 2: Task templates system for reusable task patterns
186        // Introduces the ability to save and reuse common task configurations
187        self.add_migration(2, "add_task_templates", |tx| {
188            tx.execute(
189                "CREATE TABLE IF NOT EXISTS task_templates (
190                    id INTEGER PRIMARY KEY,
191                    name TEXT NOT NULL UNIQUE,
192                    task_name TEXT NOT NULL,
193                    comment TEXT,
194                    completeness INTEGER DEFAULT 100,
195                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
196                )",
197                [],
198            )?;
199            Ok(())
200        });
201
202        // Version 3: Tags and categorization system for task organization
203        // Adds support for tagging tasks with customizable labels and colors
204        self.add_migration(3, "add_tags_system", |tx| {
205            // Main tags table for storing tag definitions
206            tx.execute(
207                "CREATE TABLE IF NOT EXISTS tags (
208                    id INTEGER PRIMARY KEY,
209                    name TEXT NOT NULL UNIQUE,
210                    color TEXT,
211                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
212                )",
213                [],
214            )?;
215
216            // Junction table for many-to-many task-tag relationships
217            tx.execute(
218                "CREATE TABLE IF NOT EXISTS task_tags (
219                    task_id INTEGER NOT NULL,
220                    tag_id INTEGER NOT NULL,
221                    PRIMARY KEY (task_id, tag_id),
222                    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
223                    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
224                )",
225                [],
226            )?;
227            Ok(())
228        });
229
230        // Version 4: Soft delete functionality for data preservation
231        // Enables logical deletion while maintaining data for auditing
232        self.add_migration(4, "add_soft_delete", |tx| {
233            tx.execute("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP", [])?;
234            tx.execute("CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at)", [])?;
235            Ok(())
236        });
237
238        // Version 5: Workday notes and annotations for context tracking
239        // Allows users to add contextual notes to their workdays
240        self.add_migration(5, "add_workday_notes", |tx| {
241            tx.execute("ALTER TABLE workdays ADD COLUMN notes TEXT", [])?;
242            Ok(())
243        });
244
245        // Version 6: Manual breaks table for productivity management
246        // Enables users to add manual break periods to improve productivity calculations
247        self.add_migration(6, "add_breaks_table", |tx| {
248            tx.execute(
249                "CREATE TABLE IF NOT EXISTS breaks (
250                    id INTEGER PRIMARY KEY,
251                    date DATE NOT NULL,
252                    start_time DATETIME NOT NULL,
253                    end_time DATETIME NOT NULL,
254                    duration INTEGER NOT NULL,
255                    reason TEXT,
256                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
257                )",
258                [],
259            )?;
260            
261            // Create index for efficient daily break lookups
262            tx.execute("CREATE INDEX idx_breaks_date ON breaks(date)", [])?;
263            Ok(())
264        });
265    }
266
267    /// Registers a single migration in the migration system.
268    ///
269    /// This helper method adds a migration to the internal registry with
270    /// proper version ordering and validation. It ensures that migrations
271    /// are stored in a consistent format for later execution.
272    ///
273    /// # Arguments
274    ///
275    /// * `version` - Unique version number for this migration
276    /// * `name` - Descriptive name for the migration's purpose
277    /// * `up` - Function that performs the actual schema transformation
278    ///
279    /// # Panics
280    ///
281    /// Panics if a migration with the same version number is already registered.
282    fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
283        self.migrations.push(Migration { version, name, up });
284    }
285
286    /// Executes all pending migrations in the correct order.
287    ///
288    /// This method performs the complete migration process:
289    /// 1. Creates the migrations tracking table if needed
290    /// 2. Determines current database version
291    /// 3. Identifies pending migrations
292    /// 4. Applies each migration within a transaction
293    /// 5. Records successful migrations in the tracking table
294    ///
295    /// ## Transaction Safety
296    ///
297    /// Each migration runs in its own transaction, ensuring that partial
298    /// failures don't leave the database in an inconsistent state. If any
299    /// migration fails, all changes are rolled back automatically.
300    ///
301    /// # Arguments
302    ///
303    /// * `conn` - Mutable database connection for applying migrations
304    ///
305    /// # Returns
306    ///
307    /// Returns `Ok(())` if all migrations succeed, or an error if any
308    /// migration fails during application.
309    ///
310    /// # Example
311    ///
312    /// ```rust
313    /// use kasl::db::migrations::MigrationManager;
314    /// use rusqlite::Connection;
315    ///
316    /// let manager = MigrationManager::new();
317    /// let mut conn = Connection::open(":memory:")?;
318    /// manager.run_migrations(&mut conn)?;
319    /// ```
320    pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
321        // Initialize the migrations tracking table
322        conn.execute(MIGRATIONS_TABLE, [])?;
323
324        // Determine the current schema version
325        let current_version = self.get_current_version(conn)?;
326
327        // Find all migrations that haven't been applied yet
328        let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
329
330        // Exit early if no migrations are needed
331        if pending.is_empty() {
332            msg_debug!("Database is up to date");
333            return Ok(());
334        }
335
336        // Notify user about pending migrations
337        msg_info!(Message::MigrationsFound(pending.len()));
338
339        // Execute all pending migrations within a single transaction
340        let tx = conn.transaction()?;
341
342        for migration in pending {
343            msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
344
345            match (migration.up)(&tx) {
346                Ok(()) => {
347                    // Record successful migration in tracking table
348                    tx.execute(
349                        "INSERT INTO migrations (version, name) VALUES (?1, ?2)",
350                        params![migration.version, migration.name],
351                    )?;
352                    msg_success!(Message::MigrationCompleted(migration.version));
353                }
354                Err(e) => {
355                    // Log migration failure and propagate error
356                    msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
357                    return Err(e);
358                }
359            }
360        }
361
362        // Commit all successful migrations
363        tx.commit()?;
364        msg_success!(Message::AllMigrationsCompleted);
365
366        Ok(())
367    }
368
369    /// Retrieves the current database schema version.
370    ///
371    /// This method queries the migrations table to determine the highest
372    /// version number that has been successfully applied. It handles the
373    /// case where no migrations have been applied yet (version 0).
374    ///
375    /// # Arguments
376    ///
377    /// * `conn` - Database connection for querying migration status
378    ///
379    /// # Returns
380    ///
381    /// Returns the current schema version number, or 0 if no migrations
382    /// have been applied yet.
383    fn get_current_version(&self, conn: &Connection) -> Result<u32> {
384        let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
385
386        Ok(version.unwrap_or(0))
387    }
388
389    /// Checks if a specific migration version has been applied.
390    ///
391    /// This utility method allows callers to verify whether a particular
392    /// migration has been successfully applied to the database. Useful
393    /// for conditional logic based on schema capabilities.
394    ///
395    /// # Arguments
396    ///
397    /// * `conn` - Database connection for querying migration status  
398    /// * `version` - Migration version number to check
399    ///
400    /// # Returns
401    ///
402    /// Returns `true` if the migration has been applied, `false` otherwise.
403    ///
404    /// # Example
405    ///
406    /// ```rust
407    /// let manager = MigrationManager::new();
408    /// if manager.is_migration_applied(&conn, 3)? {
409    ///     // Tags system is available
410    /// }
411    /// ```
412    pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
413        let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
414
415        Ok(count > 0)
416    }
417
418    /// Retrieves the complete migration history with timestamps.
419    ///
420    /// This method returns a chronological list of all applied migrations,
421    /// including their version numbers, names, and application timestamps.
422    /// Useful for auditing and debugging schema evolution.
423    ///
424    /// # Arguments
425    ///
426    /// * `conn` - Database connection for querying migration history
427    ///
428    /// # Returns
429    ///
430    /// Returns a vector of tuples containing (version, name, applied_at)
431    /// for each applied migration, ordered by version number.
432    ///
433    /// # Example
434    ///
435    /// ```rust
436    /// let manager = MigrationManager::new();
437    /// let history = manager.get_migration_history(&conn)?;
438    /// for (version, name, applied_at) in history {
439    ///     println!("v{}: {} ({})", version, name, applied_at);
440    /// }
441    /// ```
442    pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
443        let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
444
445        let history = stmt
446            .query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
447            .collect::<Result<Vec<_>, _>>()?;
448
449        Ok(history)
450    }
451
452    /// Rolls back migrations to a specific target version (debug builds only).
453    ///
454    /// This development utility allows rolling back migrations to a previous
455    /// schema version by removing migration records from the tracking table.
456    ///
457    /// ## ⚠️ Important Notes
458    ///
459    /// - Only available in debug builds for safety
460    /// - This is a simplified rollback that removes migration records
461    /// - Does not actually reverse schema changes (no down() functions)
462    /// - Primarily useful for development and testing scenarios
463    ///
464    /// # Arguments
465    ///
466    /// * `conn` - Mutable database connection for rollback operations
467    /// * `target_version` - Target version to roll back to
468    ///
469    /// # Returns
470    ///
471    /// Returns `Ok(())` if rollback succeeds, or an error if the operation fails.
472    ///
473    /// # Example
474    ///
475    /// ```rust
476    /// #[cfg(debug_assertions)]
477    /// {
478    ///     let manager = MigrationManager::new();
479    ///     manager.rollback_to(&mut conn, 2)?; // Roll back to version 2
480    /// }
481    /// ```
482    #[cfg(debug_assertions)]
483    pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
484        let current_version = self.get_current_version(conn)?;
485
486        if target_version >= current_version {
487            msg_info!(Message::NothingToRollback);
488            return Ok(());
489        }
490
491        msg_info!(Message::RollingBack(current_version, target_version));
492
493        // Remove migration records beyond the target version
494        // Note: This is a simplified rollback that doesn't actually reverse schema changes
495        conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
496
497        msg_success!(Message::RollbackCompleted(target_version));
498        Ok(())
499    }
500}
501
502/// Initializes a database connection with all pending migrations applied.
503///
504/// This convenience function creates a migration manager and applies all
505/// pending migrations to the provided connection. It's the recommended
506/// way to ensure a database is up to date with the latest schema.
507///
508/// # Arguments
509///
510/// * `conn` - Mutable database connection to initialize
511///
512/// # Returns
513///
514/// Returns `Ok(())` if initialization succeeds, or an error if migration fails.
515///
516/// # Example
517///
518/// ```rust
519/// use kasl::db::migrations::init_with_migrations;
520/// use rusqlite::Connection;
521///
522/// let mut conn = Connection::open("kasl.db")?;
523/// init_with_migrations(&mut conn)?;
524/// ```
525pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
526    let manager = MigrationManager::new();
527    manager.run_migrations(conn)?;
528    Ok(())
529}
530
531/// Retrieves the current database schema version.
532///
533/// This utility function provides a simple way to check the current
534/// schema version without creating a full migration manager instance.
535///
536/// # Arguments
537///
538/// * `conn` - Database connection to query
539///
540/// # Returns
541///
542/// Returns the current schema version number.
543///
544/// # Example
545///
546/// ```rust
547/// use kasl::db::migrations::get_db_version;
548///
549/// let version = get_db_version(&conn)?;
550/// println!("Current schema version: {}", version);
551/// ```
552pub fn get_db_version(conn: &Connection) -> Result<u32> {
553    let manager = MigrationManager::new();
554    manager.get_current_version(conn)
555}
556
557/// Checks if the database requires migration to the latest schema version.
558///
559/// This utility function compares the current database version with the
560/// latest available migration version to determine if updates are needed.
561///
562/// # Arguments
563///
564/// * `conn` - Database connection to check
565///
566/// # Returns
567///
568/// Returns `true` if migrations are needed, `false` if up to date.
569///
570/// # Example
571///
572/// ```rust
573/// use kasl::db::migrations::needs_migration;
574///
575/// if needs_migration(&conn)? {
576///     println!("Database needs migration!");
577/// }
578/// ```
579pub fn needs_migration(conn: &Connection) -> Result<bool> {
580    let manager = MigrationManager::new();
581    let current = manager.get_current_version(conn)?;
582    let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
583    Ok(current < latest)
584}