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}