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}