From 609ce5315552c36edf49cd8e96530e69eadbc28c Mon Sep 17 00:00:00 2001 From: Paul Morris <10599524+1Cor125@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:45:43 -0600 Subject: [PATCH 1/2] fix: fix theoretical race condition --- src/commands.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index fbfaf08..cff4d31 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -40,15 +40,21 @@ pub async fn load( let mut instances = db_instances.0.write().await; - // Double-check in case another thread loaded it while we waited for write lock - if instances.contains_key(&db) { - return Ok(db); + // Use entry API to atomically check and insert, avoiding race conditions + // where two callers could both create wrappers + use std::collections::hash_map::Entry; + match instances.entry(db.clone()) { + Entry::Occupied(_) => { + // Another caller won the race and inserted while we waited for write lock + Ok(db) + } + Entry::Vacant(entry) => { + // We won the race, create and insert the wrapper + let wrapper = DatabaseWrapper::connect(&db, &app, custom_config).await?; + entry.insert(wrapper); + Ok(db) + } } - - let wrapper = DatabaseWrapper::connect(&db, &app, custom_config).await?; - instances.insert(db.clone(), wrapper); - - Ok(db) } /// Execute a write query (INSERT, UPDATE, DELETE, etc.) From 5dd12b502b93d2de51dab758879aa9cf22c0664a Mon Sep 17 00:00:00 2001 From: Paul Morris <10599524+1Cor125@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:31:40 -0600 Subject: [PATCH 2/2] feat: support schema migrations - Any registered migrations are run during plugin setup, to get a jump on that work. Calls to load() await migrations so the db is ready once it's returned to the app. - Migration events can be observed from Typescript in case we need more details about progress --- Cargo.lock | 1 + README.md | 72 ++++- api-iife.js | 2 +- crates/sqlx-sqlite-conn-mgr/Cargo.toml | 5 +- crates/sqlx-sqlite-conn-mgr/README.md | 29 +- crates/sqlx-sqlite-conn-mgr/src/database.rs | 38 +++ crates/sqlx-sqlite-conn-mgr/src/error.rs | 4 + crates/sqlx-sqlite-conn-mgr/src/lib.rs | 4 + .../tests/database_tests.rs | 158 ++++++++++ guest-js/index.test.ts | 52 ++- guest-js/index.ts | 69 ++++ src/commands.rs | 74 ++++- src/lib.rs | 295 +++++++++++++++++- src/wrapper.rs | 18 +- 14 files changed, 805 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbf01fb..9eb2441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3613,6 +3613,7 @@ version = "0.8.6" dependencies = [ "serde", "sqlx", + "tempfile", "thiserror 2.0.17", "tokio", "tracing", diff --git a/README.md b/README.md index 43a14ca..9c6b37e 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ SQLite database interface for Tauri applications using > "_SQLite ... will only allow one writer at any instant in time._" * **WAL Mode**: Enabled automatically on first write operation * **Type Safety**: Full TypeScript bindings - * **Migration Support**: SQLx's migration framework (coming soon) - * **Resource Management**: Proper cleanup on application exit (coming soon) + * **Migration Support**: SQLx's migration framework + * **Resource Management**: Proper cleanup on application exit ## Architecture @@ -96,6 +96,74 @@ fn main() { } ``` +### Migrations + +This plugin uses [SQLx's migration system][sqlx-migrate]. Create numbered `.sql` +files in a migrations directory: + +[sqlx-migrate]: https://docs.rs/sqlx/latest/sqlx/macro.migrate.html + +```text +src-tauri/migrations/ +├── 0001_create_users.sql +├── 0002_add_email_column.sql +└── 0003_create_posts.sql +``` + +Register migrations using SQLx's `migrate!()` macro, which embeds them at compile time: + +```rust +use tauri_plugin_sqlite::Builder; + +fn main() { + tauri::Builder::default() + .plugin( + Builder::new() + .add_migrations("main.db", sqlx::migrate!("./migrations")) + .build() + ) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +**Timing:** Migrations start automatically at plugin setup (non-blocking). When +TypeScript calls `Database.load()`, it waits for migrations to complete before +returning. If migrations fail, `load()` returns an error. Applied migrations are +tracked in `_sqlx_migrations` — re-running is safe and idempotent. + +#### Retrieving Migration Events + +Use `getMigrationEvents()` to retrieve cached events: + +```typescript +import Database from '@silvermine/tauri-plugin-sqlite' + +const db = await Database.load('mydb.db') + +// Get all migration events (including ones emitted before listener could be registered) +const events = await db.getMigrationEvents() +for (const event of events) { + console.log(`${event.status}: ${event.dbPath}`) + if (event.status === 'failed') { + console.error(`Migration error: ${event.error}`) + } +} +``` + +**Optional:** Listen for real-time events, globally. May miss early events due the Rust +layer completing some or all migrations before the frontend subscription initializes. + +```typescript +import { listen } from '@tauri-apps/api/event' +import type { MigrationEvent } from '@silvermine/tauri-plugin-sqlite' + +await listen('sqlite:migration', (event) => { + const { dbPath, status, migrationCount, error } = event.payload + // status: 'running' | 'completed' | 'failed' +}) +``` + ### Connecting ```typescript diff --git a/api-iife.js b/api-iife.js index 0aa0187..2bae4bb 100644 --- a/api-iife.js +++ b/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_SQLITE__=function(){"use strict";async function t(t,e={},s){return window.__TAURI_INTERNALS__.invoke(t,e,s)}"function"==typeof SuppressedError&&SuppressedError;class e{constructor(t){this.path=t}static async load(s,a){const n=await t("plugin:sqlite|load",{db:s,customConfig:a});return new e(n)}static get(t){return new e(t)}async execute(e,s){const[a,n]=await t("plugin:sqlite|execute",{db:this.path,query:e,values:s??[]});return{lastInsertId:n,rowsAffected:a}}async executeTransaction(e){return await t("plugin:sqlite|execute_transaction",{db:this.path,statements:e.map(([t,e])=>({query:t,values:e??[]}))})}async fetchAll(e,s){return await t("plugin:sqlite|fetch_all",{db:this.path,query:e,values:s??[]})}async fetchOne(e,s){return await t("plugin:sqlite|fetch_one",{db:this.path,query:e,values:s??[]})}async close(){return await t("plugin:sqlite|close",{db:this.path})}static async closeAll(){await t("plugin:sqlite|close_all")}async remove(){return await t("plugin:sqlite|remove",{db:this.path})}}return e}();Object.defineProperty(window.__TAURI__,"sqlite",{value:__TAURI_PLUGIN_SQLITE__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_SQLITE__=function(){"use strict";async function t(t,e={},n){return window.__TAURI_INTERNALS__.invoke(t,e,n)}"function"==typeof SuppressedError&&SuppressedError;class e{constructor(t){this.path=t}static async load(n,s){const a=await t("plugin:sqlite|load",{db:n,customConfig:s});return new e(a)}static get(t){return new e(t)}async execute(e,n){const[s,a]=await t("plugin:sqlite|execute",{db:this.path,query:e,values:n??[]});return{lastInsertId:a,rowsAffected:s}}async executeTransaction(e){return await t("plugin:sqlite|execute_transaction",{db:this.path,statements:e.map(([t,e])=>({query:t,values:e??[]}))})}async fetchAll(e,n){return await t("plugin:sqlite|fetch_all",{db:this.path,query:e,values:n??[]})}async fetchOne(e,n){return await t("plugin:sqlite|fetch_one",{db:this.path,query:e,values:n??[]})}async close(){return await t("plugin:sqlite|close",{db:this.path})}static async closeAll(){await t("plugin:sqlite|close_all")}async remove(){return await t("plugin:sqlite|remove",{db:this.path})}async getMigrationEvents(){return await t("plugin:sqlite|get_migration_events",{db:this.path})}}return e}();Object.defineProperty(window.__TAURI__,"sqlite",{value:__TAURI_PLUGIN_SQLITE__})} diff --git a/crates/sqlx-sqlite-conn-mgr/Cargo.toml b/crates/sqlx-sqlite-conn-mgr/Cargo.toml index f687adf..072ed49 100644 --- a/crates/sqlx-sqlite-conn-mgr/Cargo.toml +++ b/crates/sqlx-sqlite-conn-mgr/Cargo.toml @@ -7,8 +7,11 @@ edition = "2024" rust-version = "1.89" [dependencies] -sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "migrate"] } thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["full"] } tracing = { version = "0.1.41", default-features = false, features = ["std", "release_max_level_off"] } serde = { version = "1.0.228", features = ["derive"] } + +[dev-dependencies] +tempfile = "3.23.0" diff --git a/crates/sqlx-sqlite-conn-mgr/README.md b/crates/sqlx-sqlite-conn-mgr/README.md index 83e8c89..ce641f8 100644 --- a/crates/sqlx-sqlite-conn-mgr/README.md +++ b/crates/sqlx-sqlite-conn-mgr/README.md @@ -66,6 +66,32 @@ let config = SqliteDatabaseConfig { let db = SqliteDatabase::connect("example.db", Some(config)).await?; ``` +### Migrations + +Run [SQLx migrations][sqlx-migrate] directly: + +[sqlx-migrate]: https://docs.rs/sqlx/latest/sqlx/macro.migrate.html + +```rust +use sqlx_sqlite_conn_mgr::SqliteDatabase; + +// Embed migrations at compile time (reads ./migrations/*.sql) +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + +async fn run() -> Result<(), sqlx_sqlite_conn_mgr::Error> { + let db = SqliteDatabase::connect("example.db", None).await?; + db.run_migrations(&MIGRATOR).await?; + Ok(()) +} +``` + +Migrations are tracked in `_sqlx_migrations` — calling `run_migrations()` multiple +times is safe (already-applied migrations are skipped). + +> **Note:** When using the Tauri plugin, migrations are handled automatically via +> `Builder::add_migrations()`. The plugin starts migrations at setup and waits for +> completion when `load()` is called. + ## API Reference ### `SqliteDatabase` @@ -75,8 +101,9 @@ let db = SqliteDatabase::connect("example.db", Some(config)).await?; | `connect(path, config)` | Connect/create database, returns cached `Arc` if already open | | `read_pool()` | Get read-only pool reference | | `acquire_writer()` | Acquire exclusive `WriteGuard` (enables WAL on first call) | +| `run_migrations(migrator)` | Run pending migrations from a `Migrator` | | `close()` | Close and remove from cache | -| `close_and_remove()` | Close and delete database files (.db, .db-wal, .db-shm) | +| `remove()` | Close and delete database files (.db, .db-wal, .db-shm) | ### `WriteGuard` diff --git a/crates/sqlx-sqlite-conn-mgr/src/database.rs b/crates/sqlx-sqlite-conn-mgr/src/database.rs index 1363303..cc13640 100644 --- a/crates/sqlx-sqlite-conn-mgr/src/database.rs +++ b/crates/sqlx-sqlite-conn-mgr/src/database.rs @@ -268,6 +268,44 @@ impl SqliteDatabase { Ok(WriteGuard::new(conn)) } + /// Run database migrations using the provided migrator + /// + /// This method runs all pending migrations from the provided `Migrator`. + /// Migrations are executed using the write connection to ensure exclusive access. + /// WAL mode is enabled automatically before running migrations. + /// + /// SQLx tracks applied migrations in a `_sqlx_migrations` table, so calling + /// this method multiple times is safe - already-applied migrations are skipped. + /// + /// # Arguments + /// + /// * `migrator` - A reference to a `Migrator` containing the migrations to run. + /// Typically created using `sqlx::migrate!()` macro. + /// + /// # Example + /// + /// ```ignore + /// use sqlx_sqlite_conn_mgr::SqliteDatabase; + /// + /// // sqlx::migrate! is evaluated at compile time + /// static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + /// + /// let db = SqliteDatabase::connect("test.db", None).await?; + /// db.run_migrations(&MIGRATOR).await?; + /// ``` + pub async fn run_migrations(&self, migrator: &sqlx::migrate::Migrator) -> Result<()> { + // Ensure WAL mode is initialized via acquire_writer + // (WriteGuard dropped immediately, returning connection to pool) + { + let _writer = self.acquire_writer().await?; + } + + // Migrator acquires its own connection from the write pool + migrator.run(&self.write_conn).await?; + + Ok(()) + } + /// Close the database and clean up resources /// /// This closes all connections in the pool and removes the database from the cache. diff --git a/crates/sqlx-sqlite-conn-mgr/src/error.rs b/crates/sqlx-sqlite-conn-mgr/src/error.rs index d3c02f5..0d78030 100644 --- a/crates/sqlx-sqlite-conn-mgr/src/error.rs +++ b/crates/sqlx-sqlite-conn-mgr/src/error.rs @@ -14,6 +14,10 @@ pub enum Error { #[error("Sqlx error: {0}")] Sqlx(#[from] sqlx::Error), + /// Migration error from the sqlx migrate framework + #[error("Migration error: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + /// Database has been closed and cannot be used #[error("Database has been closed")] DatabaseClosed, diff --git a/crates/sqlx-sqlite-conn-mgr/src/lib.rs b/crates/sqlx-sqlite-conn-mgr/src/lib.rs index 428e5af..9f1cdb1 100644 --- a/crates/sqlx-sqlite-conn-mgr/src/lib.rs +++ b/crates/sqlx-sqlite-conn-mgr/src/lib.rs @@ -8,6 +8,7 @@ //! - **[`SqliteDatabase`]**: Main database type with separate read and write connection pools //! - **[`SqliteDatabaseConfig`]**: Configuration for connection pool settings //! - **[`WriteGuard`]**: RAII guard ensuring exclusive write access +//! - **[`Migrator`]**: Re-exported from sqlx for running database migrations //! - **[`Error`]**: Error type for database operations //! //! ## Architecture @@ -71,5 +72,8 @@ pub use database::SqliteDatabase; pub use error::Error; pub use write_guard::WriteGuard; +// Re-export sqlx migrate types for convenience +pub use sqlx::migrate::Migrator; + /// A type alias for Results with our custom Error type pub type Result = std::result::Result; diff --git a/crates/sqlx-sqlite-conn-mgr/tests/database_tests.rs b/crates/sqlx-sqlite-conn-mgr/tests/database_tests.rs index 65bdb85..86fa435 100644 --- a/crates/sqlx-sqlite-conn-mgr/tests/database_tests.rs +++ b/crates/sqlx-sqlite-conn-mgr/tests/database_tests.rs @@ -1,5 +1,7 @@ +use sqlx::migrate::Migrator; use sqlx_sqlite_conn_mgr::{Error, SqliteDatabase, SqliteDatabaseConfig}; use std::sync::Arc; +use tempfile::TempDir; #[tokio::test] async fn test_concurrent_reads() { @@ -30,6 +32,7 @@ async fn test_concurrent_reads() { .fetch_one(db.read_pool().unwrap()) .await .unwrap(); + assert_eq!(count, 12); active.fetch_sub(1, Ordering::SeqCst); @@ -292,6 +295,7 @@ async fn test_write_serialization() { .execute(&mut *w) .await .unwrap(); + active.fetch_sub(1, Ordering::SeqCst); }) }) @@ -348,6 +352,7 @@ async fn test_concurrent_reads_and_writes() { .execute(&mut *w) .await .unwrap(); + write_active.store(false, Ordering::SeqCst); }) }; @@ -366,6 +371,7 @@ async fn test_concurrent_reads_and_writes() { .fetch_one(db.read_pool().unwrap()) .await .unwrap(); + if write_active.load(Ordering::SeqCst) { read_during_write.store(true, Ordering::SeqCst); } @@ -383,3 +389,155 @@ async fn test_concurrent_reads_and_writes() { db.remove().await.unwrap(); } + +/// Helper to create a temp directory with migration files. +/// Returns (TempDir, Migrator) - TempDir must be kept alive for Migrator to work. +async fn create_migrations(migrations: &[(&str, &str)]) -> (TempDir, Migrator) { + let dir = TempDir::new().unwrap(); + + for (i, (name, sql)) in migrations.iter().enumerate() { + let filename = format!("{:04}_{}.sql", i + 1, name.replace(' ', "_")); + std::fs::write(dir.path().join(filename), sql).unwrap(); + } + + let migrator = Migrator::new(dir.path()).await.unwrap(); + (dir, migrator) +} + +#[tokio::test] +async fn test_run_migrations_creates_schema() { + let path = std::env::current_dir() + .unwrap() + .join("test_migrations_multi.db"); + + let db = SqliteDatabase::connect(&path, None).await.unwrap(); + + let (_dir, migrator) = create_migrations(&[ + ( + "create_users", + "CREATE TABLE users (id INTEGER PRIMARY KEY);", + ), + ( + "create_posts", + "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);", + ), + ( + "add_index", + "CREATE INDEX idx_posts_user ON posts(user_id);", + ), + ]) + .await; + + db.run_migrations(&migrator).await.unwrap(); + + // Verify all migrations applied + let (count,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_sqlx_%'", + ) + .fetch_one(db.read_pool().unwrap()) + .await + .unwrap(); + + assert_eq!(count, 3, "should have 2 tables + 1 index"); + + db.remove().await.unwrap(); +} + +#[tokio::test] +async fn test_run_migrations_idempotent() { + let path = std::env::current_dir() + .unwrap() + .join("test_migrations_idempotent.db"); + + let db = SqliteDatabase::connect(&path, None).await.unwrap(); + + let (_dir, migrator) = create_migrations(&[( + "create_items", + "CREATE TABLE items (id INTEGER PRIMARY KEY);", + )]) + .await; + + // Run twice - second should be no-op + db.run_migrations(&migrator).await.unwrap(); + db.run_migrations(&migrator).await.unwrap(); + + // Verify table exists (no duplicate error) + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sqlite_master WHERE name = 'items'") + .fetch_one(db.read_pool().unwrap()) + .await + .unwrap(); + + assert_eq!(count, 1); + + db.remove().await.unwrap(); +} + +#[tokio::test] +async fn test_run_migrations_tracks_in_sqlx_table() { + let path = std::env::current_dir() + .unwrap() + .join("test_migrations_tracking.db"); + + let db = SqliteDatabase::connect(&path, None).await.unwrap(); + + let (_dir, migrator) = create_migrations(&[ + ("first", "CREATE TABLE t1 (id INTEGER);"), + ("second", "CREATE TABLE t2 (id INTEGER);"), + ]) + .await; + + db.run_migrations(&migrator).await.unwrap(); + + // Verify _sqlx_migrations table has 2 records + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations") + .fetch_one(db.read_pool().unwrap()) + .await + .unwrap(); + + assert_eq!(count, 2, "should track 2 applied migrations"); + + db.remove().await.unwrap(); +} + +#[tokio::test] +async fn test_run_migrations_on_closed_db_errors() { + let path = std::env::current_dir() + .unwrap() + .join("test_migrations_closed.db"); + + let db = SqliteDatabase::connect(&path, None).await.unwrap(); + let db_ref = Arc::clone(&db); + + db.close().await.unwrap(); + + let (_dir, migrator) = create_migrations(&[("noop", "SELECT 1;")]).await; + let result = db_ref.run_migrations(&migrator).await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::DatabaseClosed)); + + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn test_run_migrations_with_invalid_sql_fails() { + let path = std::env::current_dir() + .unwrap() + .join("test_migrations_invalid.db"); + + let db = SqliteDatabase::connect(&path, None).await.unwrap(); + + // Create migration with invalid SQL syntax + let (_dir, migrator) = create_migrations(&[ + ("valid", "CREATE TABLE users (id INTEGER PRIMARY KEY);"), + ("invalid", "THIS IS NOT VALID SQL SYNTAX"), + ]) + .await; + + let result = db.run_migrations(&migrator).await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::Migration(_))); + + db.remove().await.unwrap(); +} diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index bfd6002..8d3c352 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mockIPC, clearMocks } from '@tauri-apps/api/mocks' -import Database from './index' +import Database, { MigrationEvent } from './index' let lastCmd = '' let lastArgs: Record = {} @@ -74,6 +74,24 @@ describe('Database commands', () => { expect(lastArgs.db).toBe('t.db') }) + it('getMigrationEvents', async () => { + const mockEvents: MigrationEvent[] = [ + { dbPath: 't.db', status: 'running' }, + { dbPath: 't.db', status: 'completed', migrationCount: 5 } + ] + mockIPC((cmd, args) => { + lastCmd = cmd + lastArgs = args as Record + if (cmd === 'plugin:sqlite|get_migration_events') return mockEvents + return undefined + }) + + const events = await Database.get('t.db').getMigrationEvents() + expect(lastCmd).toBe('plugin:sqlite|get_migration_events') + expect(lastArgs.db).toBe('t.db') + expect(events).toEqual(mockEvents) + }) + it('handles errors from backend', async () => { mockIPC(() => { throw new Error('Database error') @@ -81,3 +99,35 @@ describe('Database commands', () => { await expect(Database.get('t.db').execute('SELECT 1', [])).rejects.toThrow('Database error') }) }) + +describe('MigrationEvent type', () => { + it('accepts running status', () => { + const event: MigrationEvent = { + dbPath: 'test.db', + status: 'running', + } + expect(event.status).toBe('running') + expect(event.migrationCount).toBeUndefined() + expect(event.error).toBeUndefined() + }) + + it('accepts completed status with migrationCount', () => { + const event: MigrationEvent = { + dbPath: 'test.db', + status: 'completed', + migrationCount: 3, + } + expect(event.status).toBe('completed') + expect(event.migrationCount).toBe(3) + }) + + it('accepts failed status with error', () => { + const event: MigrationEvent = { + dbPath: 'test.db', + status: 'failed', + error: 'Migration failed: syntax error', + } + expect(event.status).toBe('failed') + expect(event.error).toBe('Migration failed: syntax error') + }) +}) diff --git a/guest-js/index.ts b/guest-js/index.ts index b1caa75..5a606d8 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -48,6 +48,44 @@ export interface CustomConfig { idleTimeoutSecs?: number } +/** + * Event payload emitted during database migration operations. + * + * Listen for these events to track migration progress: + * + * @example + * ```ts + * import { listen } from '@tauri-apps/api/event' + * import type { MigrationEvent } from '@silvermine/tauri-plugin-sqlite' + * + * await listen('sqlite:migration', (event) => { + * const { dbPath, status, migrationCount, error } = event.payload + * + * switch (status) { + * case 'running': + * console.log(`Running migrations for ${dbPath}`) + * break + * case 'completed': + * console.log(`Completed ${migrationCount} migrations for ${dbPath}`) + * break + * case 'failed': + * console.error(`Migration failed for ${dbPath}: ${error}`) + * break + * } + * }) + * ``` + */ +export interface MigrationEvent { + /** Database path (relative, as registered with the plugin) */ + dbPath: string + /** Status: "running", "completed", "failed" */ + status: 'running' | 'completed' | 'failed' + /** Total number of migrations in the migrator (on "completed"), not just newly applied */ + migrationCount?: number + /** Error message (on "failed") */ + error?: string +} + /** * **Database** * @@ -326,4 +364,35 @@ export default class Database { }) return success } + + /** + * **getMigrationEvents** + * + * Retrieves all cached migration events for this database. + * + * This method solves the race condition where migrations complete before the + * frontend can register an event listener. Events are cached on the backend + * and can be retrieved at any time. + * + * @returns Array of all migration events that have occurred for this database + * + * @example + * ```ts + * const db = await Database.load('mydb.db') + * + * // Get all migration events (including ones that happened before we could listen) + * const events = await db.getMigrationEvents() + * for (const event of events) { + * console.log(`${event.status}: ${event.dbPath}`) + * if (event.status === 'failed') { + * console.error(`Migration error: ${event.error}`) + * } + * } + * ``` + */ + async getMigrationEvents(): Promise { + return await invoke('plugin:sqlite|get_migration_events', { + db: this.path + }) + } } diff --git a/src/commands.rs b/src/commands.rs index cff4d31..0ed6dcd 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -9,7 +9,10 @@ use serde_json::Value as JsonValue; use sqlx_sqlite_conn_mgr::SqliteDatabaseConfig; use tauri::{AppHandle, Runtime, State}; -use crate::{DbInstances, Error, Result, WriteQueryResult, wrapper::DatabaseWrapper}; +use crate::{ + DbInstances, Error, MigrationEvent, MigrationStates, MigrationStatus, Result, WriteQueryResult, + wrapper::DatabaseWrapper, +}; /// Statement in a transaction with query and bind values #[derive(Debug, Deserialize)] @@ -22,13 +25,25 @@ pub struct Statement { /// /// If the database is already loaded, returns the existing connection. /// Otherwise, creates a new connection with optional custom configuration. +/// +/// # Migration Timing +/// +/// If migrations are registered for this database, this function waits for them +/// to complete before proceeding. The migration task (spawned at plugin setup) +/// already called `SqliteDatabase::connect()`, which cached the database instance. +/// When we call `connect()` here, we get the **same cached instance** from the +/// registry - so we're not creating duplicate connections. #[tauri::command] pub async fn load( app: AppHandle, db_instances: State<'_, DbInstances>, + migration_states: State<'_, MigrationStates>, db: String, custom_config: Option, ) -> Result { + // Wait for migrations to complete if registered for this database + await_migrations(&migration_states, &db).await?; + let instances = db_instances.0.read().await; // Return cached if db was already loaded @@ -57,6 +72,44 @@ pub async fn load( } } +/// Wait for migrations to complete for a database, if any are registered. +/// +/// Returns Ok(()) if: +/// - No migrations are registered for this database +/// - Migrations completed successfully +/// +/// Returns Err if migrations failed. +async fn await_migrations(migration_states: &State<'_, MigrationStates>, db: &str) -> Result<()> { + loop { + // Get notify handle before checking status + let notify = { + let states = migration_states.0.read().await; + match states.get(db) { + // No migrations registered for this database + None => return Ok(()), + + Some(state) => match &state.status { + // Migrations completed successfully + MigrationStatus::Complete => return Ok(()), + + // Migrations failed - return the error + MigrationStatus::Failed(error) => { + return Err(Error::Migration(sqlx::migrate::MigrateError::Source( + error.clone().into(), + ))); + } + + // Migrations still pending or running - wait for notification + MigrationStatus::Pending | MigrationStatus::Running => state.notify.clone(), + }, + } + }; + + // Wait for migration state change + notify.notified().await; + } +} + /// Execute a write query (INSERT, UPDATE, DELETE, etc.) #[tauri::command] pub async fn execute( @@ -185,3 +238,22 @@ pub async fn remove(db_instances: State<'_, DbInstances>, db: String) -> Result< Ok(false) // Database wasn't loaded } } + +/// Get cached migration events for a database. +/// +/// Returns all migration events that have been emitted for the specified database. +/// This allows the frontend to retrieve events even if they were missed due to timing. +/// +/// Returns an empty array if no migrations are registered for this database. +#[tauri::command] +pub async fn get_migration_events( + migration_states: State<'_, MigrationStates>, + db: String, +) -> Result> { + let states = migration_states.0.read().await; + + match states.get(&db) { + Some(state) => Ok(state.events.clone()), + None => Ok(Vec::new()), + } +} diff --git a/src/lib.rs b/src/lib.rs index 6d38e1e..c09773d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::sync::Arc; -use tauri::{Manager, RunEvent, Runtime, plugin::Builder as PluginBuilder}; -use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; +use serde::Serialize; +use sqlx_sqlite_conn_mgr::Migrator; +use tauri::{Emitter, Manager, RunEvent, Runtime, plugin::Builder as PluginBuilder}; +use tokio::sync::{Notify, RwLock}; +use tracing::{debug, error, info, trace, warn}; mod commands; mod decode; @@ -11,6 +13,7 @@ mod error; mod wrapper; pub use error::{Error, Result}; +pub use sqlx_sqlite_conn_mgr::Migrator as SqliteMigrator; pub use wrapper::{DatabaseWrapper, WriteQueryResult}; /// Database instances managed by the plugin. @@ -20,6 +23,65 @@ pub use wrapper::{DatabaseWrapper, WriteQueryResult}; #[derive(Clone, Default)] pub struct DbInstances(pub Arc>>); +/// Migration status for a database. +#[derive(Debug, Clone)] +pub enum MigrationStatus { + /// Migrations are pending (not yet started) + Pending, + /// Migrations are currently running + Running, + /// Migrations completed successfully + Complete, + /// Migrations failed with an error + Failed(String), +} + +/// Tracks migration state for a single database with notification support. +pub struct MigrationState { + pub(crate) status: MigrationStatus, + pub(crate) notify: Arc, + pub(crate) events: Vec, +} + +impl MigrationState { + fn new() -> Self { + Self { + status: MigrationStatus::Pending, + notify: Arc::new(Notify::new()), + events: Vec::new(), + } + } + + fn update_status(&mut self, status: MigrationStatus) { + self.status = status; + self.notify.notify_waiters(); + } + + fn cache_event(&mut self, event: MigrationEvent) { + self.events.push(event); + } +} + +/// Tracks migration state for all databases. +#[derive(Default)] +pub struct MigrationStates(pub RwLock>); + +/// Event payload emitted during migration operations. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationEvent { + /// Database path (relative, as registered) + pub db_path: String, + /// Status: "running", "completed", "failed" + pub status: String, + /// Total number of migrations in the migrator (on "completed"), not just newly applied + #[serde(skip_serializing_if = "Option::is_none")] + pub migration_count: Option, + /// Error message (on "failed") + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// Builder for the SQLite plugin. /// /// Use this to configure the plugin and build the plugin instance. @@ -29,23 +91,69 @@ pub struct DbInstances(pub Arc>>); /// ```ignore /// use tauri_plugin_sqlite::Builder; /// -/// // In your Tauri app setup: +/// // Basic setup (no migrations): /// tauri::Builder::default() /// .plugin(Builder::new().build()) /// .run(tauri::generate_context!()) /// .expect("error while running tauri application"); /// ``` +/// +/// # Example with migrations +/// +/// ```ignore +/// use tauri_plugin_sqlite::Builder; +/// +/// // Setup with migrations: +/// tauri::Builder::default() +/// .plugin( +/// Builder::new() +/// .add_migrations("main.db", sqlx::migrate!("./migrations/main")) +/// .add_migrations("cache.db", sqlx::migrate!("./migrations/cache")) +/// .build() +/// ) +/// .run(tauri::generate_context!()) +/// .expect("error while running tauri application"); +/// ``` #[derive(Default)] -pub struct Builder; +pub struct Builder { + /// Migrations registered per database path + migrations: HashMap>, +} impl Builder { /// Create a new builder instance. pub fn new() -> Self { - Self + Self { + migrations: HashMap::new(), + } + } + + /// Register migrations for a database path. + /// + /// Migrations will be run automatically at plugin initialization. + /// Multiple databases can have their own migrations. + /// + /// # Arguments + /// + /// * `path` - Database path (relative to app config directory) + /// * `migrator` - Migrator instance, typically from `sqlx::migrate!()` + /// + /// # Example + /// + /// ```ignore + /// Builder::new() + /// .add_migrations("main.db", sqlx::migrate!("./migrations")) + /// .build() + /// ``` + pub fn add_migrations(mut self, path: &str, migrator: Migrator) -> Self { + self.migrations.insert(path.to_string(), Arc::new(migrator)); + self } /// Build the plugin with command registration and state management. pub fn build(self) -> tauri::plugin::TauriPlugin { + let migrations = Arc::new(self.migrations); + PluginBuilder::::new("sqlite") .invoke_handler(tauri::generate_handler![ commands::load, @@ -56,11 +164,37 @@ impl Builder { commands::close, commands::close_all, commands::remove, + commands::get_migration_events, ]) - .setup(|app, _api| { + .setup(move |app, _api| { app.manage(DbInstances::default()); + app.manage(MigrationStates::default()); + + // Initialize migration states as Pending for all registered databases + let migration_states = app.state::(); + { + let mut states = migration_states.0.blocking_write(); + for path in migrations.keys() { + states.insert(path.clone(), MigrationState::new()); + } + } + + // Spawn parallel migration tasks for each registered database + if !migrations.is_empty() { + info!("Starting migrations for {} database(s)", migrations.len()); + + for (path, migrator) in migrations.iter() { + let app_handle = app.clone(); + let path = path.clone(); + let migrator = Arc::clone(migrator); + + tokio::spawn(async move { + run_migrations_for_database(app_handle, path, migrator).await; + }); + } + } + debug!("SQLite plugin initialized"); - // Future PR: Possibly handle migrations here Ok(()) }) .on_event(|app, event| { @@ -160,3 +294,148 @@ impl Builder { pub fn init() -> tauri::plugin::TauriPlugin { Builder::new().build() } + +/// Run migrations for a single database and emit events. +/// +/// This function is spawned as a task for each database with registered migrations. +/// It runs during plugin setup, before the frontend calls `load`. +/// +/// # Timing & Caching +/// +/// 1. Plugin setup spawns this task (async, non-blocking) +/// 2. This task connects via `SqliteDatabase::connect()`, which caches the instance +/// 3. When frontend later calls `load`, it awaits migration completion first +/// 4. Then `load` calls `connect()` again, which returns the **same cached instance** +/// +/// The `DatabaseWrapper` created here is temporary and dropped after migrations complete, +/// but the underlying `SqliteDatabase` (with its connection pools) remains cached in the +/// global registry and is reused when `load` creates its own wrapper. +async fn run_migrations_for_database( + app: tauri::AppHandle, + path: String, + migrator: Arc, +) { + let migration_states = app.state::(); + + // Update state to Running + { + let mut states = migration_states.0.write().await; + if let Some(state) = states.get_mut(&path) { + state.update_status(MigrationStatus::Running); + } + } + + // Emit running event + emit_migration_event(&app, &path, "running", None, None); + + // Resolve absolute path and connect + let abs_path = match resolve_migration_path(&path, &app) { + Ok(p) => p, + Err(e) => { + let error_msg = e.to_string(); + error!( + "Failed to resolve migration path for {}: {}", + path, error_msg + ); + + let mut states = migration_states.0.write().await; + if let Some(state) = states.get_mut(&path) { + state.update_status(MigrationStatus::Failed(error_msg.clone())); + } + + emit_migration_event(&app, &path, "failed", None, Some(error_msg)); + return; + } + }; + + // Connect to database + let db = match DatabaseWrapper::connect_with_path(&abs_path, None).await { + Ok(wrapper) => wrapper, + Err(e) => { + let error_msg = e.to_string(); + error!("Failed to connect for migrations {}: {}", path, error_msg); + + let mut states = migration_states.0.write().await; + if let Some(state) = states.get_mut(&path) { + state.update_status(MigrationStatus::Failed(error_msg.clone())); + } + + emit_migration_event(&app, &path, "failed", None, Some(error_msg)); + return; + } + }; + + // Run migrations + // Note: SQLx's migrator.run() doesn't provide per-migration callbacks, + // so we can only report start and finish. For detailed per-migration events, + // we would need to iterate migrations manually. + trace!("Running migrations for {}", path); + + match db.run_migrations(&migrator).await { + Ok(()) => { + info!("Migrations completed successfully for {}", path); + + let mut states = migration_states.0.write().await; + if let Some(state) = states.get_mut(&path) { + state.update_status(MigrationStatus::Complete); + } + + let migration_count = migrator.iter().count(); + emit_migration_event(&app, &path, "completed", Some(migration_count), None); + } + Err(e) => { + let error_msg = e.to_string(); + error!("Migration failed for {}: {}", path, error_msg); + + let mut states = migration_states.0.write().await; + if let Some(state) = states.get_mut(&path) { + state.update_status(MigrationStatus::Failed(error_msg.clone())); + } + + emit_migration_event(&app, &path, "failed", None, Some(error_msg)); + } + } +} + +/// Emit a migration event to the frontend and cache it. +fn emit_migration_event( + app: &tauri::AppHandle, + db_path: &str, + status: &str, + migration_count: Option, + error: Option, +) { + let event = MigrationEvent { + db_path: db_path.to_string(), + status: status.to_string(), + migration_count, + error, + }; + + // Cache event in migration state + let migration_states = app.state::(); + if let Ok(mut states) = migration_states.0.try_write() + && let Some(state) = states.get_mut(db_path) + { + state.cache_event(event.clone()); + } + + if let Err(e) = app.emit("sqlite:migration", &event) { + warn!("Failed to emit migration event: {}", e); + } +} + +/// Resolve database path for migrations (similar to wrapper but accessible at init). +fn resolve_migration_path( + path: &str, + app: &tauri::AppHandle, +) -> Result { + let app_path = app + .path() + .app_config_dir() + .map_err(|_| Error::InvalidPath("No app config path found".to_string()))?; + + std::fs::create_dir_all(&app_path).map_err(Error::Io)?; + + Ok(app_path.join(path)) +} diff --git a/src/wrapper.rs b/src/wrapper.rs index 86970c7..1b60766 100644 --- a/src/wrapper.rs +++ b/src/wrapper.rs @@ -44,7 +44,11 @@ impl DatabaseWrapper { /// Connect to a SQLite database with an absolute path. /// /// This is the core connection method used by `connect()`. It's also - /// exposed for testing purposes where we don't have a Tauri AppHandle. + /// used by the migration task during plugin setup. + /// + /// Note: `SqliteDatabase::connect()` caches instances in a global registry. + /// Multiple calls with the same path return the same underlying database, + /// so this wrapper is lightweight - the actual connection pools are shared. pub async fn connect_with_path( abs_path: &std::path::Path, custom_config: Option, @@ -207,6 +211,18 @@ impl DatabaseWrapper { } } + /// Run database migrations + /// + /// Runs all pending migrations from the provided migrator. + /// SQLx tracks applied migrations, so this is safe to call multiple times. + pub async fn run_migrations( + &self, + migrator: &sqlx_sqlite_conn_mgr::Migrator, + ) -> Result<(), Error> { + self.inner.run_migrations(migrator).await?; + Ok(()) + } + /// Close the database connection pub async fn close(self) -> Result<(), Error> { // Close via Arc (handles both owned and shared cases)