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::{Connection, Transaction, params};
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 Default for MigrationManager {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl MigrationManager {
91    /// Creates a new migration manager with all registered migrations.
92    ///
93    /// This constructor automatically registers all available migrations
94    /// in the correct order. The registration process is deterministic
95    /// and ensures consistent schema evolution across all environments.
96    ///
97    /// # Returns
98    ///
99    /// Returns a fully initialized migration manager ready to apply
100    /// pending schema changes.
101    ///
102    /// # Example
103    ///
104    /// ```rust
105    /// use kasl::db::migrations::MigrationManager;
106    ///
107    /// let manager = MigrationManager::new();
108    /// // Manager is ready to apply migrations
109    /// ```
110    pub fn new() -> Self {
111        let mut manager = Self { migrations: Vec::new() };
112
113        // Register all migrations in chronological order
114        // Each registration adds a migration to the internal registry
115        manager.register_migrations();
116        manager
117    }
118
119    /// Registers all database migrations in chronological order.
120    ///
121    /// This method defines the complete schema evolution history by registering
122    /// each migration version with its transformation logic. Migrations must
123    /// be registered in sequential version order to ensure correct application.
124    ///
125    /// ## Migration Design Principles
126    ///
127    /// - **Incremental**: Each migration makes small, focused changes
128    /// - **Idempotent**: Migrations can be safely re-run if needed
129    /// - **Forward-Only**: No backward compatibility requirements
130    /// - **Atomic**: Each migration succeeds or fails completely
131    fn register_migrations(&mut self) {
132        // Initial schema - version 0 is implicit (empty database)
133        // Base tables are created by individual modules as needed
134
135        // Version 1: Base tables and performance indices
136        // Creates fundamental tables and adds indices for better performance
137        self.add_migration(1, "create_tables_and_indices", |tx| {
138            // First, create base tables that individual modules depend on
139            // This ensures tables exist before any indices are created
140
141            // Create tasks table
142            tx.execute(
143                "CREATE TABLE IF NOT EXISTS tasks (
144        id INTEGER NOT NULL PRIMARY KEY,
145        task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
146        timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
147        name TEXT NOT NULL,
148        comment TEXT,
149        completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
150        excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
151    )",
152                [],
153            )?;
154
155            // Create pauses table
156            tx.execute(
157                "CREATE TABLE IF NOT EXISTS pauses (
158        id INTEGER NOT NULL PRIMARY KEY,
159        start TIMESTAMP NOT NULL,
160        end TIMESTAMP,
161        duration INTEGER
162    )",
163                [],
164            )?;
165
166            // Create workdays table
167            tx.execute(
168                "CREATE TABLE IF NOT EXISTS workdays (
169        id INTEGER PRIMARY KEY,
170        date DATE NOT NULL UNIQUE,
171        start TIMESTAMP NOT NULL,
172        end TIMESTAMP
173    )",
174                [],
175            )?;
176
177            // Now create indices for the tables we just created
178
179            // Index tasks by timestamp for chronological queries
180            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_timestamp ON tasks(timestamp)", [])?;
181            // Index tasks by parent task relationship
182            tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id)", [])?;
183            // Index pauses by start time for temporal queries
184            tx.execute("CREATE INDEX IF NOT EXISTS idx_pauses_start ON pauses(start)", [])?;
185            // Index workdays by date for daily/monthly reporting
186            tx.execute("CREATE INDEX IF NOT EXISTS idx_workdays_date ON workdays(date)", [])?;
187
188            Ok(())
189        });
190
191        // Version 2: Task templates system for reusable task patterns
192        // Introduces the ability to save and reuse common task configurations
193        self.add_migration(2, "add_task_templates", |tx| {
194            tx.execute(
195                "CREATE TABLE IF NOT EXISTS task_templates (
196                    id INTEGER PRIMARY KEY,
197                    name TEXT NOT NULL UNIQUE,
198                    task_name TEXT NOT NULL,
199                    comment TEXT,
200                    completeness INTEGER DEFAULT 100,
201                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
202                )",
203                [],
204            )?;
205            Ok(())
206        });
207
208        // Version 3: Tags and categorization system for task organization
209        // Adds support for tagging tasks with customizable labels and colors
210        self.add_migration(3, "add_tags_system", |tx| {
211            // Main tags table for storing tag definitions
212            tx.execute(
213                "CREATE TABLE IF NOT EXISTS tags (
214                    id INTEGER PRIMARY KEY,
215                    name TEXT NOT NULL UNIQUE,
216                    color TEXT,
217                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
218                )",
219                [],
220            )?;
221
222            // Junction table for many-to-many task-tag relationships
223            tx.execute(
224                "CREATE TABLE IF NOT EXISTS task_tags (
225                    task_id INTEGER NOT NULL,
226                    tag_id INTEGER NOT NULL,
227                    PRIMARY KEY (task_id, tag_id),
228                    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
229                    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
230                )",
231                [],
232            )?;
233            Ok(())
234        });
235
236        // Version 4: Soft delete functionality for data preservation
237        // Enables logical deletion while maintaining data for auditing
238        self.add_migration(4, "add_soft_delete", |tx| {
239            tx.execute("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP", [])?;
240            tx.execute("CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at)", [])?;
241            Ok(())
242        });
243
244        // Version 5: Workday notes and annotations for context tracking
245        // Allows users to add contextual notes to their workdays
246        self.add_migration(5, "add_workday_notes", |tx| {
247            tx.execute("ALTER TABLE workdays ADD COLUMN notes TEXT", [])?;
248            Ok(())
249        });
250
251        // Version 6: Manual breaks table for productivity management
252        // Enables users to add manual break periods to improve productivity calculations
253        self.add_migration(6, "add_breaks_table", |tx| {
254            tx.execute(
255                "CREATE TABLE IF NOT EXISTS breaks (
256                    id INTEGER PRIMARY KEY,
257                    date DATE NOT NULL,
258                    start_time DATETIME NOT NULL,
259                    end_time DATETIME NOT NULL,
260                    duration INTEGER NOT NULL,
261                    reason TEXT,
262                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
263                )",
264                [],
265            )?;
266
267            // Create index for efficient daily break lookups
268            tx.execute("CREATE INDEX idx_breaks_date ON breaks(date)", [])?;
269            Ok(())
270        });
271
272        // Version 7: Jira inbox for assigned open issues and toast notifications
273        self.add_migration(7, "add_jira_inbox_table", |tx| {
274            tx.execute(
275                "CREATE TABLE IF NOT EXISTS jira_inbox (
276                    issue_key TEXT PRIMARY KEY NOT NULL,
277                    issue_id TEXT NOT NULL,
278                    summary TEXT NOT NULL,
279                    status TEXT NOT NULL,
280                    priority TEXT,
281                    priority_rank INTEGER NOT NULL DEFAULT 999,
282                    url TEXT NOT NULL,
283                    first_seen TIMESTAMP NOT NULL,
284                    last_seen TIMESTAMP NOT NULL,
285                    notified INTEGER NOT NULL DEFAULT 0,
286                    pinned INTEGER NOT NULL DEFAULT 0,
287                    dismissed INTEGER NOT NULL DEFAULT 0,
288                    raw_updated TEXT
289                )",
290                [],
291            )?;
292            tx.execute(
293                "CREATE INDEX IF NOT EXISTS idx_jira_inbox_active
294                 ON jira_inbox(dismissed, pinned DESC, priority_rank ASC, last_seen DESC)",
295                [],
296            )?;
297            Ok(())
298        });
299
300        // Version 8: status catalog + scoring/sort_value for inbox ranking
301        self.add_migration(8, "jira_inbox_status_id_and_sort_value", |tx| {
302            tx.execute(
303                "CREATE TABLE IF NOT EXISTS jira_statuses (
304                    id TEXT PRIMARY KEY NOT NULL,
305                    name TEXT NOT NULL
306                )",
307                [],
308            )?;
309            tx.execute("ALTER TABLE jira_inbox ADD COLUMN status_id TEXT", [])?;
310            tx.execute("ALTER TABLE jira_inbox ADD COLUMN sort_value REAL", [])?;
311            tx.execute(
312                "CREATE INDEX IF NOT EXISTS idx_jira_inbox_sort
313                 ON jira_inbox(dismissed, pinned DESC, sort_value DESC, priority_rank ASC)",
314                [],
315            )?;
316            Ok(())
317        });
318
319        // Version 9: wipe legacy status name strings; use status_id + jira_statuses only
320        self.add_migration(9, "clear_jira_inbox_legacy_status_text", |tx| {
321            tx.execute("UPDATE jira_inbox SET status = ''", [])?;
322            Ok(())
323        });
324
325        // Version 10: drop unused legacy status text column (canonical: status_id)
326        self.add_migration(10, "drop_jira_inbox_legacy_status_column", |tx| {
327            tx.execute("ALTER TABLE jira_inbox DROP COLUMN status", [])?;
328            Ok(())
329        });
330    }
331
332    /// Registers a single migration in the migration system.
333    ///
334    /// This helper method adds a migration to the internal registry with
335    /// proper version ordering and validation. It ensures that migrations
336    /// are stored in a consistent format for later execution.
337    ///
338    /// # Arguments
339    ///
340    /// * `version` - Unique version number for this migration
341    /// * `name` - Descriptive name for the migration's purpose
342    /// * `up` - Function that performs the actual schema transformation
343    ///
344    /// # Panics
345    ///
346    /// Panics if a migration with the same version number is already registered.
347    fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
348        self.migrations.push(Migration { version, name, up });
349    }
350
351    /// Executes all pending migrations in the correct order.
352    ///
353    /// This method performs the complete migration process:
354    /// 1. Creates the migrations tracking table if needed
355    /// 2. Determines current database version
356    /// 3. Identifies pending migrations
357    /// 4. Applies each migration within a transaction
358    /// 5. Records successful migrations in the tracking table
359    ///
360    /// ## Transaction Safety
361    ///
362    /// Each migration runs in its own transaction, ensuring that partial
363    /// failures don't leave the database in an inconsistent state. If any
364    /// migration fails, all changes are rolled back automatically.
365    ///
366    /// # Arguments
367    ///
368    /// * `conn` - Mutable database connection for applying migrations
369    ///
370    /// # Returns
371    ///
372    /// Returns `Ok(())` if all migrations succeed, or an error if any
373    /// migration fails during application.
374    ///
375    /// # Example
376    ///
377    /// ```rust
378    /// use kasl::db::migrations::MigrationManager;
379    /// use rusqlite::Connection;
380    ///
381    /// let manager = MigrationManager::new();
382    /// let mut conn = Connection::open(":memory:")?;
383    /// manager.run_migrations(&mut conn)?;
384    /// ```
385    pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
386        // Initialize the migrations tracking table
387        conn.execute(MIGRATIONS_TABLE, [])?;
388
389        // Determine the current schema version
390        let current_version = self.get_current_version(conn)?;
391
392        // Find all migrations that haven't been applied yet
393        let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
394
395        // Exit early if no migrations are needed
396        if pending.is_empty() {
397            msg_debug!("Database is up to date");
398            return Ok(());
399        }
400
401        // Notify user about pending migrations
402        msg_info!(Message::MigrationsFound(pending.len()));
403
404        // Execute all pending migrations within a single transaction
405        let tx = conn.transaction()?;
406
407        for migration in pending {
408            msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
409
410            match (migration.up)(&tx) {
411                Ok(()) => {
412                    // Record successful migration in tracking table
413                    tx.execute(
414                        "INSERT INTO migrations (version, name) VALUES (?1, ?2)",
415                        params![migration.version, migration.name],
416                    )?;
417                    msg_success!(Message::MigrationCompleted(migration.version));
418                }
419                Err(e) => {
420                    // Log migration failure and propagate error
421                    msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
422                    return Err(e);
423                }
424            }
425        }
426
427        // Commit all successful migrations
428        tx.commit()?;
429        msg_success!(Message::AllMigrationsCompleted);
430
431        Ok(())
432    }
433
434    /// Retrieves the current database schema version.
435    ///
436    /// This method queries the migrations table to determine the highest
437    /// version number that has been successfully applied. It handles the
438    /// case where no migrations have been applied yet (version 0).
439    ///
440    /// # Arguments
441    ///
442    /// * `conn` - Database connection for querying migration status
443    ///
444    /// # Returns
445    ///
446    /// Returns the current schema version number, or 0 if no migrations
447    /// have been applied yet.
448    fn get_current_version(&self, conn: &Connection) -> Result<u32> {
449        let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
450
451        Ok(version.unwrap_or(0))
452    }
453
454    /// Checks if a specific migration version has been applied.
455    ///
456    /// This utility method allows callers to verify whether a particular
457    /// migration has been successfully applied to the database. Useful
458    /// for conditional logic based on schema capabilities.
459    ///
460    /// # Arguments
461    ///
462    /// * `conn` - Database connection for querying migration status  
463    /// * `version` - Migration version number to check
464    ///
465    /// # Returns
466    ///
467    /// Returns `true` if the migration has been applied, `false` otherwise.
468    ///
469    /// # Example
470    ///
471    /// ```rust
472    /// let manager = MigrationManager::new();
473    /// if manager.is_migration_applied(&conn, 3)? {
474    ///     // Tags system is available
475    /// }
476    /// ```
477    pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
478        let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
479
480        Ok(count > 0)
481    }
482
483    /// Retrieves the complete migration history with timestamps.
484    ///
485    /// This method returns a chronological list of all applied migrations,
486    /// including their version numbers, names, and application timestamps.
487    /// Useful for auditing and debugging schema evolution.
488    ///
489    /// # Arguments
490    ///
491    /// * `conn` - Database connection for querying migration history
492    ///
493    /// # Returns
494    ///
495    /// Returns a vector of tuples containing (version, name, applied_at)
496    /// for each applied migration, ordered by version number.
497    ///
498    /// # Example
499    ///
500    /// ```rust
501    /// let manager = MigrationManager::new();
502    /// let history = manager.get_migration_history(&conn)?;
503    /// for (version, name, applied_at) in history {
504    ///     println!("v{}: {} ({})", version, name, applied_at);
505    /// }
506    /// ```
507    pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
508        let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
509
510        let history = stmt
511            .query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
512            .collect::<Result<Vec<_>, _>>()?;
513
514        Ok(history)
515    }
516
517    /// Rolls back migrations to a specific target version (debug builds only).
518    ///
519    /// This development utility allows rolling back migrations to a previous
520    /// schema version by removing migration records from the tracking table.
521    ///
522    /// ## ⚠️ Important Notes
523    ///
524    /// - Only available in debug builds for safety
525    /// - This is a simplified rollback that removes migration records
526    /// - Does not actually reverse schema changes (no down() functions)
527    /// - Primarily useful for development and testing scenarios
528    ///
529    /// # Arguments
530    ///
531    /// * `conn` - Mutable database connection for rollback operations
532    /// * `target_version` - Target version to roll back to
533    ///
534    /// # Returns
535    ///
536    /// Returns `Ok(())` if rollback succeeds, or an error if the operation fails.
537    ///
538    /// # Example
539    ///
540    /// ```rust
541    /// #[cfg(debug_assertions)]
542    /// {
543    ///     let manager = MigrationManager::new();
544    ///     manager.rollback_to(&mut conn, 2)?; // Roll back to version 2
545    /// }
546    /// ```
547    #[cfg(debug_assertions)]
548    pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
549        let current_version = self.get_current_version(conn)?;
550
551        if target_version >= current_version {
552            msg_info!(Message::NothingToRollback);
553            return Ok(());
554        }
555
556        msg_info!(Message::RollingBack(current_version, target_version));
557
558        // Remove migration records beyond the target version
559        // Note: This is a simplified rollback that doesn't actually reverse schema changes
560        conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
561
562        msg_success!(Message::RollbackCompleted(target_version));
563        Ok(())
564    }
565}
566
567/// Initializes a database connection with all pending migrations applied.
568///
569/// This convenience function creates a migration manager and applies all
570/// pending migrations to the provided connection. It's the recommended
571/// way to ensure a database is up to date with the latest schema.
572///
573/// # Arguments
574///
575/// * `conn` - Mutable database connection to initialize
576///
577/// # Returns
578///
579/// Returns `Ok(())` if initialization succeeds, or an error if migration fails.
580///
581/// # Example
582///
583/// ```rust
584/// use kasl::db::migrations::init_with_migrations;
585/// use rusqlite::Connection;
586///
587/// let mut conn = Connection::open("kasl.db")?;
588/// init_with_migrations(&mut conn)?;
589/// ```
590pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
591    let manager = MigrationManager::new();
592    manager.run_migrations(conn)?;
593    Ok(())
594}
595
596/// Retrieves the current database schema version.
597///
598/// This utility function provides a simple way to check the current
599/// schema version without creating a full migration manager instance.
600///
601/// # Arguments
602///
603/// * `conn` - Database connection to query
604///
605/// # Returns
606///
607/// Returns the current schema version number.
608///
609/// # Example
610///
611/// ```rust
612/// use kasl::db::migrations::get_db_version;
613///
614/// let version = get_db_version(&conn)?;
615/// println!("Current schema version: {}", version);
616/// ```
617pub fn get_db_version(conn: &Connection) -> Result<u32> {
618    let manager = MigrationManager::new();
619    manager.get_current_version(conn)
620}
621
622/// Checks if the database requires migration to the latest schema version.
623///
624/// This utility function compares the current database version with the
625/// latest available migration version to determine if updates are needed.
626///
627/// # Arguments
628///
629/// * `conn` - Database connection to check
630///
631/// # Returns
632///
633/// Returns `true` if migrations are needed, `false` if up to date.
634///
635/// # Example
636///
637/// ```rust
638/// use kasl::db::migrations::needs_migration;
639///
640/// if needs_migration(&conn)? {
641///     println!("Database needs migration!");
642/// }
643/// ```
644pub fn needs_migration(conn: &Connection) -> Result<bool> {
645    let manager = MigrationManager::new();
646    let current = manager.get_current_version(conn)?;
647    let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
648    Ok(current < latest)
649}