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//! ## Usage
7//!
8//! ```rust,no_run
9//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
10//! use kasl::db::migrations::{init_with_migrations, get_db_version};
11//! use rusqlite::Connection;
12//!
13//! let mut conn = Connection::open("kasl.db")?;
14//! init_with_migrations(&mut conn)?;
15//! let version = get_db_version(&conn)?;
16//! # Ok(())
17//! # }
18//! ```
19
20use crate::libs::messages::Message;
21use crate::{msg_debug, msg_error, msg_info, msg_success};
22use anyhow::Result;
23use rusqlite::{Connection, Transaction, params};
24
25/// SQL schema for the migrations tracking table.
26///
27/// This table maintains a complete record of all applied migrations,
28/// enabling version tracking and providing an audit trail of schema changes.
29/// Each migration is recorded with its version, name, and application timestamp.
30const MIGRATIONS_TABLE: &str = "
31CREATE TABLE IF NOT EXISTS migrations (
32 id INTEGER PRIMARY KEY,
33 version INTEGER NOT NULL UNIQUE,
34 name TEXT NOT NULL,
35 applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
36)";
37
38/// Represents a single database migration with execution logic.
39///
40/// Each migration contains the information needed to apply a specific
41/// schema change, including version tracking and the transformation function.
42/// Migrations are designed to be immutable and deterministic.
43#[derive(Debug, Clone)]
44struct Migration {
45 /// Unique version number for ordering and tracking
46 version: u32,
47 /// Human-readable name describing the migration's purpose
48 name: &'static str,
49 /// Function that applies the schema changes within a transaction
50 up: fn(&Transaction) -> Result<()>,
51}
52
53/// Central migration system manager that orchestrates schema evolution.
54///
55/// The `MigrationManager` maintains the complete registry of available migrations
56/// and provides the logic for applying them in the correct order. It ensures
57/// that migrations are applied atomically and tracks their completion status.
58pub struct MigrationManager {
59 /// Ordered list of all available migrations
60 ///
61 /// Migrations are stored in version order to ensure correct application
62 /// sequence. Each migration builds upon the schema state created by
63 /// its predecessors.
64 migrations: Vec<Migration>,
65}
66
67impl Default for MigrationManager {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl MigrationManager {
74 /// Creates a new migration manager with all registered migrations.
75 ///
76 /// # Example
77 ///
78 /// ```rust
79 /// use kasl::db::migrations::MigrationManager;
80 ///
81 /// let manager = MigrationManager::new();
82 /// // Manager is ready to apply migrations
83 /// ```
84 pub fn new() -> Self {
85 let mut manager = Self { migrations: Vec::new() };
86
87 // Register all migrations in chronological order
88 // Each registration adds a migration to the internal registry
89 manager.register_migrations();
90 manager
91 }
92
93 /// Registers all database migrations in chronological order.
94 fn register_migrations(&mut self) {
95 // Initial schema - version 0 is implicit (empty database)
96 // Base tables are created by individual modules as needed
97
98 // Version 1: Base tables and performance indices
99 // Creates fundamental tables and adds indices for better performance
100 self.add_migration(1, "create_tables_and_indices", |tx| {
101 // First, create base tables that individual modules depend on
102 // This ensures tables exist before any indices are created
103
104 // Create tasks table
105 tx.execute(
106 "CREATE TABLE IF NOT EXISTS tasks (
107 id INTEGER NOT NULL PRIMARY KEY,
108 task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
109 timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
110 name TEXT NOT NULL,
111 comment TEXT,
112 completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
113 excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
114 )",
115 [],
116 )?;
117
118 // Create pauses table
119 tx.execute(
120 "CREATE TABLE IF NOT EXISTS pauses (
121 id INTEGER NOT NULL PRIMARY KEY,
122 start TIMESTAMP NOT NULL,
123 end TIMESTAMP,
124 duration INTEGER
125 )",
126 [],
127 )?;
128
129 // Create workdays table
130 tx.execute(
131 "CREATE TABLE IF NOT EXISTS workdays (
132 id INTEGER PRIMARY KEY,
133 date DATE NOT NULL UNIQUE,
134 start TIMESTAMP NOT NULL,
135 end TIMESTAMP
136 )",
137 [],
138 )?;
139
140 // Now create indices for the tables we just created
141
142 // Index tasks by timestamp for chronological queries
143 tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_timestamp ON tasks(timestamp)", [])?;
144 // Index tasks by parent task relationship
145 tx.execute("CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id)", [])?;
146 // Index pauses by start time for temporal queries
147 tx.execute("CREATE INDEX IF NOT EXISTS idx_pauses_start ON pauses(start)", [])?;
148 // Index workdays by date for daily/monthly reporting
149 tx.execute("CREATE INDEX IF NOT EXISTS idx_workdays_date ON workdays(date)", [])?;
150
151 Ok(())
152 });
153
154 // Version 2: Task templates system for reusable task patterns
155 // Introduces the ability to save and reuse common task configurations
156 self.add_migration(2, "add_task_templates", |tx| {
157 tx.execute(
158 "CREATE TABLE IF NOT EXISTS task_templates (
159 id INTEGER PRIMARY KEY,
160 name TEXT NOT NULL UNIQUE,
161 task_name TEXT NOT NULL,
162 comment TEXT,
163 completeness INTEGER DEFAULT 100,
164 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
165 )",
166 [],
167 )?;
168 Ok(())
169 });
170
171 // Version 3: Tags and categorization system for task organization
172 // Adds support for tagging tasks with customizable labels and colors
173 self.add_migration(3, "add_tags_system", |tx| {
174 // Main tags table for storing tag definitions
175 tx.execute(
176 "CREATE TABLE IF NOT EXISTS tags (
177 id INTEGER PRIMARY KEY,
178 name TEXT NOT NULL UNIQUE,
179 color TEXT,
180 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
181 )",
182 [],
183 )?;
184
185 // Junction table for many-to-many task-tag relationships
186 tx.execute(
187 "CREATE TABLE IF NOT EXISTS task_tags (
188 task_id INTEGER NOT NULL,
189 tag_id INTEGER NOT NULL,
190 PRIMARY KEY (task_id, tag_id),
191 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
192 FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
193 )",
194 [],
195 )?;
196 Ok(())
197 });
198
199 // Version 4: Soft delete functionality for data preservation
200 // Enables logical deletion while maintaining data for auditing
201 self.add_migration(4, "add_soft_delete", |tx| {
202 tx.execute("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP", [])?;
203 tx.execute("CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at)", [])?;
204 Ok(())
205 });
206
207 // Version 5: Workday notes and annotations for context tracking
208 // Allows users to add contextual notes to their workdays
209 self.add_migration(5, "add_workday_notes", |tx| {
210 tx.execute("ALTER TABLE workdays ADD COLUMN notes TEXT", [])?;
211 Ok(())
212 });
213
214 // Version 6: Manual breaks table for productivity management
215 // Enables users to add manual break periods to improve productivity calculations
216 self.add_migration(6, "add_breaks_table", |tx| {
217 tx.execute(
218 "CREATE TABLE IF NOT EXISTS breaks (
219 id INTEGER PRIMARY KEY,
220 date DATE NOT NULL,
221 start_time DATETIME NOT NULL,
222 end_time DATETIME NOT NULL,
223 duration INTEGER NOT NULL,
224 reason TEXT,
225 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
226 )",
227 [],
228 )?;
229
230 // Create index for efficient daily break lookups
231 tx.execute("CREATE INDEX idx_breaks_date ON breaks(date)", [])?;
232 Ok(())
233 });
234
235 // Version 7: Jira inbox for assigned open issues and toast notifications
236 self.add_migration(7, "add_jira_inbox_table", |tx| {
237 tx.execute(
238 "CREATE TABLE IF NOT EXISTS jira_inbox (
239 issue_key TEXT PRIMARY KEY NOT NULL,
240 issue_id TEXT NOT NULL,
241 summary TEXT NOT NULL,
242 status TEXT NOT NULL,
243 priority TEXT,
244 priority_rank INTEGER NOT NULL DEFAULT 999,
245 url TEXT NOT NULL,
246 first_seen TIMESTAMP NOT NULL,
247 last_seen TIMESTAMP NOT NULL,
248 notified INTEGER NOT NULL DEFAULT 0,
249 pinned INTEGER NOT NULL DEFAULT 0,
250 dismissed INTEGER NOT NULL DEFAULT 0,
251 raw_updated TEXT
252 )",
253 [],
254 )?;
255 tx.execute(
256 "CREATE INDEX IF NOT EXISTS idx_jira_inbox_active
257 ON jira_inbox(dismissed, pinned DESC, priority_rank ASC, last_seen DESC)",
258 [],
259 )?;
260 Ok(())
261 });
262
263 // Version 8: status catalog + scoring/sort_value for inbox ranking
264 self.add_migration(8, "jira_inbox_status_id_and_sort_value", |tx| {
265 tx.execute(
266 "CREATE TABLE IF NOT EXISTS jira_statuses (
267 id TEXT PRIMARY KEY NOT NULL,
268 name TEXT NOT NULL
269 )",
270 [],
271 )?;
272 tx.execute("ALTER TABLE jira_inbox ADD COLUMN status_id TEXT", [])?;
273 tx.execute("ALTER TABLE jira_inbox ADD COLUMN sort_value REAL", [])?;
274 tx.execute(
275 "CREATE INDEX IF NOT EXISTS idx_jira_inbox_sort
276 ON jira_inbox(dismissed, pinned DESC, sort_value DESC, priority_rank ASC)",
277 [],
278 )?;
279 Ok(())
280 });
281
282 // Version 9: wipe legacy status name strings; use status_id + jira_statuses only
283 self.add_migration(9, "clear_jira_inbox_legacy_status_text", |tx| {
284 tx.execute("UPDATE jira_inbox SET status = ''", [])?;
285 Ok(())
286 });
287
288 // Version 10: drop unused legacy status text column (canonical: status_id)
289 self.add_migration(10, "drop_jira_inbox_legacy_status_column", |tx| {
290 tx.execute("ALTER TABLE jira_inbox DROP COLUMN status", [])?;
291 Ok(())
292 });
293
294 // Version 11: fold manual breaks into pauses as protected records.
295 //
296 // The separate `breaks` table held synthetic records whose times were
297 // invented by a placement heuristic; downstream code converted them to
298 // pauses anyway. Manual breaks now live in `pauses` with `protected = 1`,
299 // which exempts them from the duration threshold and from merging with
300 // adjacent pauses. Existing break rows are carried over so historical
301 // reports keep their numbers.
302 self.add_migration(11, "fold_breaks_into_protected_pauses", |tx| {
303 tx.execute("ALTER TABLE pauses ADD COLUMN protected INTEGER NOT NULL DEFAULT 0", [])?;
304 tx.execute("ALTER TABLE pauses ADD COLUMN reason TEXT", [])?;
305
306 // Carry over manual breaks; duration is stored in seconds in `pauses`
307 // but was stored in minutes in `breaks`.
308 tx.execute(
309 "INSERT INTO pauses (start, end, duration, protected, reason)
310 SELECT start_time, end_time, duration * 60, 1, reason FROM breaks",
311 [],
312 )?;
313
314 tx.execute("DROP INDEX IF EXISTS idx_breaks_date", [])?;
315 tx.execute("DROP TABLE IF EXISTS breaks", [])?;
316 Ok(())
317 });
318
319 // Version 12: inbox reconciliation and change tracking.
320 //
321 // `gone_at` marks issues that stopped appearing in the Jira poll
322 // (closed, reassigned) so the list stops showing them instead of
323 // freezing on the first sync. `last_change`/`changed_at` record the
324 // most recent visible change (status, priority, score) for badges
325 // and toasts.
326 self.add_migration(12, "jira_inbox_gone_and_change_tracking", |tx| {
327 tx.execute("ALTER TABLE jira_inbox ADD COLUMN gone_at TIMESTAMP", [])?;
328 tx.execute("ALTER TABLE jira_inbox ADD COLUMN last_change TEXT", [])?;
329 tx.execute("ALTER TABLE jira_inbox ADD COLUMN changed_at TIMESTAMP", [])?;
330 Ok(())
331 });
332
333 // Version 13: the link between an inbox issue and the task it became.
334 //
335 // `take` used to create a task and dismiss the issue, which severed
336 // the two: the key survived only inside the task's name, and the
337 // inbox forgot the issue had ever been picked up. `tasks.jira_key`
338 // records which issue a task came from, and `jira_inbox.taken_at`
339 // keeps the issue in the list marked as taken rather than hiding it.
340 //
341 // Dismissal stays what it always was - "not my problem" - so rows
342 // dismissed before this migration are left alone.
343 self.add_migration(13, "link_taken_issues_to_their_tasks", |tx| {
344 tx.execute("ALTER TABLE tasks ADD COLUMN jira_key TEXT", [])?;
345 tx.execute("CREATE INDEX idx_tasks_jira_key ON tasks(jira_key)", [])?;
346 tx.execute("ALTER TABLE jira_inbox ADD COLUMN taken_at TIMESTAMP", [])?;
347 Ok(())
348 });
349
350 // Version 14: the outbox of days still owed to kasl-server.
351 //
352 // A row is a date, not a payload. The day is rebuilt from the local
353 // tables when it is finally sent, so a week spent offline delivers the
354 // day as it stands at delivery rather than as it stood when the
355 // network first failed - the employee's later correction is the one
356 // that lands, which is also the rule the server plays by (last upload
357 // wins, ADR 0004 in kasl-server).
358 //
359 // `date` is unique: owing a day twice is the same debt, and a queue
360 // that grew a row per failed attempt would send a week's retries as a
361 // week's worth of duplicate days.
362 //
363 // `last_error` and `attempts` are for the person, not the machine.
364 // Nothing branches on them; they answer "why is this still here" when
365 // a day refuses to leave, which is otherwise invisible.
366 self.add_migration(14, "add_server_outbox", |tx| {
367 tx.execute(
368 "CREATE TABLE IF NOT EXISTS server_outbox (
369 id INTEGER PRIMARY KEY,
370 date DATE NOT NULL UNIQUE,
371 queued_at TIMESTAMP NOT NULL,
372 attempts INTEGER NOT NULL DEFAULT 0,
373 last_attempt_at TIMESTAMP,
374 last_error TEXT
375 )",
376 [],
377 )?;
378 // Oldest first is the order the queue is drained in, and the only
379 // order it is ever read in.
380 tx.execute("CREATE INDEX IF NOT EXISTS idx_server_outbox_date ON server_outbox(date)", [])?;
381 Ok(())
382 });
383
384 self.add_migration(15, "snooze_inbox_issues", |tx| {
385 // Snoozing is "not now", which dismissal could never say: a
386 // dismissed issue is gone for good, so the only way to defer one
387 // was to leave it in the list and keep reading past it.
388 tx.execute("ALTER TABLE jira_inbox ADD COLUMN snoozed_until TIMESTAMP", [])?;
389 // When the issue came back, so the return can be announced once
390 // and not on every poll after it.
391 tx.execute("ALTER TABLE jira_inbox ADD COLUMN woke_at TIMESTAMP", [])?;
392 // The waking query asks for rows due before now; the index is what
393 // keeps that off a full scan once the inbox is two hundred rows.
394 tx.execute("CREATE INDEX IF NOT EXISTS idx_jira_inbox_snoozed ON jira_inbox(snoozed_until)", [])?;
395 Ok(())
396 });
397
398 // The pulse's last word, kept so `kasl server status` - a different
399 // process from the watcher that sends it - can say how it is going.
400 //
401 // One row, never a log: the server keeps only the latest claim (ADR
402 // 0014 in kasl-server), and a local minute-by-minute history of when
403 // someone was at their desk would be the record the server refuses
404 // to be. `CHECK (id = 1)` makes a second row unwritable rather than
405 // merely unwritten.
406 //
407 // Times are UTC: the only question asked of them is "how long ago",
408 // and a wall-clock stamp answers that wrongly for an hour twice a year.
409 self.add_migration(16, "add_server_pulse", |tx| {
410 tx.execute(
411 "CREATE TABLE IF NOT EXISTS server_pulse (
412 id INTEGER PRIMARY KEY CHECK (id = 1),
413 attempted_at TIMESTAMP NOT NULL,
414 next_at TIMESTAMP NOT NULL,
415 sent_at TIMESTAMP,
416 state TEXT,
417 stale_after_seconds INTEGER,
418 clock_skew_seconds INTEGER,
419 error TEXT
420 )",
421 [],
422 )?;
423 Ok(())
424 });
425 }
426
427 /// Registers a single migration in the migration system.
428 ///
429 /// This helper method adds a migration to the internal registry with
430 /// proper version ordering and validation. It ensures that migrations
431 /// are stored in a consistent format for later execution.
432 ///
433 /// # Panics
434 ///
435 /// Panics if a migration with the same version number is already registered.
436 fn add_migration(&mut self, version: u32, name: &'static str, up: fn(&Transaction) -> Result<()>) {
437 self.migrations.push(Migration { version, name, up });
438 }
439
440 /// Executes all pending migrations in the correct order.
441 ///
442 /// # Example
443 ///
444 /// ```rust
445 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
446 /// use kasl::db::migrations::MigrationManager;
447 /// use rusqlite::Connection;
448 ///
449 /// let manager = MigrationManager::new();
450 /// let mut conn = Connection::open(":memory:")?;
451 /// manager.run_migrations(&mut conn)?;
452 /// # Ok(())
453 /// # }
454 /// ```
455 pub fn run_migrations(&self, conn: &mut Connection) -> Result<()> {
456 // Initialize the migrations tracking table
457 conn.execute(MIGRATIONS_TABLE, [])?;
458
459 // Determine the current schema version
460 let current_version = self.get_current_version(conn)?;
461
462 // Find all migrations that haven't been applied yet
463 let pending: Vec<&Migration> = self.migrations.iter().filter(|m| m.version > current_version).collect();
464
465 // Exit early if no migrations are needed
466 if pending.is_empty() {
467 msg_debug!("Database is up to date");
468 return Ok(());
469 }
470
471 // Notify user about pending migrations
472 msg_info!(Message::MigrationsFound(pending.len()));
473
474 // Execute all pending migrations within a single transaction
475 let tx = conn.transaction()?;
476
477 for migration in pending {
478 msg_info!(Message::RunningMigration(migration.version, migration.name.to_string()));
479
480 match (migration.up)(&tx) {
481 Ok(()) => {
482 // Record successful migration in tracking table
483 tx.execute(
484 "INSERT INTO migrations (version, name) VALUES (?1, ?2)",
485 params![migration.version, migration.name],
486 )?;
487 msg_success!(Message::MigrationCompleted(migration.version));
488 }
489 Err(e) => {
490 // Log migration failure and propagate error
491 msg_error!(Message::MigrationFailed(migration.version, e.to_string()));
492 return Err(e);
493 }
494 }
495 }
496
497 // Commit all successful migrations
498 tx.commit()?;
499 msg_success!(Message::AllMigrationsCompleted);
500
501 Ok(())
502 }
503
504 /// Retrieves the current database schema version.
505 fn get_current_version(&self, conn: &Connection) -> Result<u32> {
506 let version: Option<u32> = conn.query_row("SELECT MAX(version) FROM migrations", [], |row| row.get(0)).unwrap_or(Some(0));
507
508 Ok(version.unwrap_or(0))
509 }
510
511 /// Checks if a specific migration version has been applied.
512 ///
513 /// This utility method allows callers to verify whether a particular
514 /// migration has been successfully applied to the database. Useful
515 /// for conditional logic based on schema capabilities.
516 ///
517 /// # Example
518 ///
519 /// ```rust
520 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
521 /// use kasl::db::migrations::MigrationManager;
522 /// use rusqlite::Connection;
523 ///
524 /// let manager = MigrationManager::new();
525 /// let mut conn = Connection::open(":memory:")?;
526 /// manager.run_migrations(&mut conn)?;
527 /// if manager.is_migration_applied(&conn, 3)? {
528 /// // Tags system is available
529 /// }
530 /// # Ok(())
531 /// # }
532 /// ```
533 pub fn is_migration_applied(&self, conn: &Connection, version: u32) -> Result<bool> {
534 let count: i32 = conn.query_row("SELECT COUNT(*) FROM migrations WHERE version = ?1", params![version], |row| row.get(0))?;
535
536 Ok(count > 0)
537 }
538
539 /// Retrieves the complete migration history with timestamps.
540 ///
541 /// # Example
542 ///
543 /// ```rust
544 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
545 /// use kasl::db::migrations::MigrationManager;
546 /// use rusqlite::Connection;
547 ///
548 /// let manager = MigrationManager::new();
549 /// let mut conn = Connection::open(":memory:")?;
550 /// manager.run_migrations(&mut conn)?;
551 /// let history = manager.get_migration_history(&conn)?;
552 /// for (version, name, applied_at) in history {
553 /// println!("v{}: {} ({})", version, name, applied_at);
554 /// }
555 /// # Ok(())
556 /// # }
557 /// ```
558 pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(u32, String, String)>> {
559 let mut stmt = conn.prepare("SELECT version, name, applied_at FROM migrations ORDER BY version")?;
560
561 let history = stmt
562 .query_map([], |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)))?
563 .collect::<Result<Vec<_>, _>>()?;
564
565 Ok(history)
566 }
567
568 /// Rolls back migrations to a specific target version (debug builds only).
569 ///
570 /// This development utility allows rolling back migrations to a previous
571 /// schema version by removing migration records from the tracking table.
572 ///
573 /// # Example
574 ///
575 /// ```rust
576 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
577 /// use kasl::db::migrations::MigrationManager;
578 /// use rusqlite::Connection;
579 ///
580 /// #[cfg(debug_assertions)]
581 /// {
582 /// let manager = MigrationManager::new();
583 /// let mut conn = Connection::open(":memory:")?;
584 /// manager.run_migrations(&mut conn)?;
585 /// manager.rollback_to(&mut conn, 2)?; // Roll back to version 2
586 /// }
587 /// # Ok(())
588 /// # }
589 /// ```
590 #[cfg(debug_assertions)]
591 pub fn rollback_to(&self, conn: &mut Connection, target_version: u32) -> Result<()> {
592 let current_version = self.get_current_version(conn)?;
593
594 if target_version >= current_version {
595 msg_info!(Message::NothingToRollback);
596 return Ok(());
597 }
598
599 msg_info!(Message::RollingBack(current_version, target_version));
600
601 // Remove migration records beyond the target version
602 // Note: This is a simplified rollback that doesn't actually reverse schema changes
603 conn.execute("DELETE FROM migrations WHERE version > ?1", params![target_version])?;
604
605 msg_success!(Message::RollbackCompleted(target_version));
606 Ok(())
607 }
608}
609
610/// Initializes a database connection with all pending migrations applied.
611///
612/// This convenience function creates a migration manager and applies all
613/// pending migrations to the provided connection. It's the recommended
614/// way to ensure a database is up to date with the latest schema.
615///
616/// # Example
617///
618/// ```rust,no_run
619/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
620/// use kasl::db::migrations::init_with_migrations;
621/// use rusqlite::Connection;
622///
623/// let mut conn = Connection::open("kasl.db")?;
624/// init_with_migrations(&mut conn)?;
625/// # Ok(())
626/// # }
627/// ```
628pub fn init_with_migrations(conn: &mut Connection) -> Result<()> {
629 let manager = MigrationManager::new();
630 manager.run_migrations(conn)?;
631 Ok(())
632}
633
634/// Retrieves the current database schema version.
635///
636/// This utility function provides a simple way to check the current
637/// schema version without creating a full migration manager instance.
638///
639/// # Example
640///
641/// ```rust
642/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
643/// use kasl::db::migrations::get_db_version;
644/// use rusqlite::Connection;
645///
646/// let conn = Connection::open(":memory:")?;
647/// let version = get_db_version(&conn)?;
648/// println!("Current schema version: {}", version);
649/// # Ok(())
650/// # }
651/// ```
652pub fn get_db_version(conn: &Connection) -> Result<u32> {
653 let manager = MigrationManager::new();
654 manager.get_current_version(conn)
655}
656
657/// Checks if the database requires migration to the latest schema version.
658///
659/// This utility function compares the current database version with the
660/// latest available migration version to determine if updates are needed.
661///
662/// # Example
663///
664/// ```rust
665/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
666/// use kasl::db::migrations::needs_migration;
667/// use rusqlite::Connection;
668///
669/// let conn = Connection::open(":memory:")?;
670/// if needs_migration(&conn)? {
671/// println!("Database needs migration!");
672/// }
673/// # Ok(())
674/// # }
675/// ```
676pub fn needs_migration(conn: &Connection) -> Result<bool> {
677 let manager = MigrationManager::new();
678 let current = manager.get_current_version(conn)?;
679 let latest = manager.migrations.last().map(|m| m.version).unwrap_or(0);
680 Ok(current < latest)
681}