Skip to content

Commit 27b7fb3

Browse files
committed
feat: support schema migrations
1 parent ca3407d commit 27b7fb3

11 files changed

Lines changed: 600 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sqlx-sqlite-conn-mgr/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ edition = "2024"
77
rust-version = "1.89"
88

99
[dependencies]
10-
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
10+
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "migrate"] }
1111
thiserror = "2.0.17"
1212
tokio = { version = "1.48.0", features = ["full"] }
1313
tracing = { version = "0.1.41", default-features = false, features = ["std", "release_max_level_off"] }
1414
serde = { version = "1.0.228", features = ["derive"] }
15+
16+
[dev-dependencies]
17+
tempfile = "3.23.0"

crates/sqlx-sqlite-conn-mgr/src/database.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,62 @@ impl SqliteDatabase {
268268
Ok(WriteGuard::new(conn))
269269
}
270270

271+
/// Run database migrations using the provided migrator
272+
///
273+
/// This method runs all pending migrations from the provided `Migrator`.
274+
/// Migrations are executed using the write connection to ensure exclusive access.
275+
/// WAL mode is enabled automatically before running migrations.
276+
///
277+
/// SQLx tracks applied migrations in a `_sqlx_migrations` table, so calling
278+
/// this method multiple times is safe - already-applied migrations are skipped.
279+
///
280+
/// # Arguments
281+
///
282+
/// * `migrator` - A reference to a `Migrator` containing the migrations to run.
283+
/// Typically created using `sqlx::migrate!()` macro.
284+
///
285+
/// # Example
286+
///
287+
/// ```ignore
288+
/// use sqlx_sqlite_conn_mgr::SqliteDatabase;
289+
///
290+
/// // sqlx::migrate! is evaluated at compile time
291+
/// static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
292+
///
293+
/// let db = SqliteDatabase::connect("test.db", None).await?;
294+
/// db.run_migrations(&MIGRATOR).await?;
295+
/// ```
296+
pub async fn run_migrations(&self, migrator: &sqlx::migrate::Migrator) -> Result<()> {
297+
if self.closed.load(Ordering::SeqCst) {
298+
return Err(Error::DatabaseClosed);
299+
}
300+
301+
// Initialize WAL mode before running migrations (migrations are writes)
302+
// Acquire writer to ensure WAL is set up
303+
let mut conn = self.write_conn.acquire().await?;
304+
305+
if !self.wal_initialized.load(Ordering::SeqCst) {
306+
sqlx::query("PRAGMA journal_mode = WAL")
307+
.execute(&mut *conn)
308+
.await?;
309+
310+
sqlx::query("PRAGMA synchronous = NORMAL")
311+
.execute(&mut *conn)
312+
.await?;
313+
314+
self.wal_initialized.store(true, Ordering::SeqCst);
315+
}
316+
317+
// Drop the connection back to pool before running migrator
318+
// (migrator.run() will acquire its own connection)
319+
drop(conn);
320+
321+
// Run migrations using the write pool
322+
migrator.run(&self.write_conn).await?;
323+
324+
Ok(())
325+
}
326+
271327
/// Close the database and clean up resources
272328
///
273329
/// This closes all connections in the pool and removes the database from the cache.

crates/sqlx-sqlite-conn-mgr/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ pub enum Error {
1414
#[error("Sqlx error: {0}")]
1515
Sqlx(#[from] sqlx::Error),
1616

17+
/// Migration error from the sqlx migrate framework
18+
#[error("Migration error: {0}")]
19+
Migration(#[from] sqlx::migrate::MigrateError),
20+
1721
/// Database has been closed and cannot be used
1822
#[error("Database has been closed")]
1923
DatabaseClosed,

crates/sqlx-sqlite-conn-mgr/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//! - **[`SqliteDatabase`]**: Main database type with separate read and write connection pools
99
//! - **[`SqliteDatabaseConfig`]**: Configuration for connection pool settings
1010
//! - **[`WriteGuard`]**: RAII guard ensuring exclusive write access
11+
//! - **[`Migrator`]**: Re-exported from sqlx for running database migrations
1112
//! - **[`Error`]**: Error type for database operations
1213
//!
1314
//! ## Architecture
@@ -71,5 +72,8 @@ pub use database::SqliteDatabase;
7172
pub use error::Error;
7273
pub use write_guard::WriteGuard;
7374

75+
// Re-export sqlx migrate types for convenience
76+
pub use sqlx::migrate::Migrator;
77+
7478
/// A type alias for Results with our custom Error type
7579
pub type Result<T> = std::result::Result<T, Error>;

crates/sqlx-sqlite-conn-mgr/tests/database_tests.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
use sqlx::migrate::Migrator;
12
use sqlx_sqlite_conn_mgr::{Error, SqliteDatabase, SqliteDatabaseConfig};
23
use std::sync::Arc;
4+
use tempfile::TempDir;
35

46
#[tokio::test]
57
async fn test_concurrent_reads() {
@@ -383,3 +385,175 @@ async fn test_concurrent_reads_and_writes() {
383385

384386
db.remove().await.unwrap();
385387
}
388+
389+
/// Helper to create a temp directory with migration files.
390+
/// Returns (TempDir, Migrator) - TempDir must be kept alive for Migrator to work.
391+
async fn create_migrations(migrations: &[(&str, &str)]) -> (TempDir, Migrator) {
392+
let dir = TempDir::new().unwrap();
393+
394+
for (i, (name, sql)) in migrations.iter().enumerate() {
395+
let filename = format!("{:04}_{}.sql", i + 1, name.replace(' ', "_"));
396+
std::fs::write(dir.path().join(filename), sql).unwrap();
397+
}
398+
399+
let migrator = Migrator::new(dir.path()).await.unwrap();
400+
(dir, migrator)
401+
}
402+
403+
#[tokio::test]
404+
async fn test_run_migrations_creates_tables() {
405+
let path = std::env::current_dir()
406+
.unwrap()
407+
.join("test_migrations_creates.db");
408+
let db = SqliteDatabase::connect(&path, None).await.unwrap();
409+
410+
let (_dir, migrator) = create_migrations(&[(
411+
"create_users",
412+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
413+
)])
414+
.await;
415+
416+
db.run_migrations(&migrator).await.unwrap();
417+
418+
// Verify table exists
419+
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sqlite_master WHERE name = 'users'")
420+
.fetch_one(db.read_pool().unwrap())
421+
.await
422+
.unwrap();
423+
424+
assert_eq!(count, 1, "users table should exist");
425+
426+
db.remove().await.unwrap();
427+
}
428+
429+
#[tokio::test]
430+
async fn test_run_migrations_multiple() {
431+
let path = std::env::current_dir()
432+
.unwrap()
433+
.join("test_migrations_multi.db");
434+
let db = SqliteDatabase::connect(&path, None).await.unwrap();
435+
436+
let (_dir, migrator) = create_migrations(&[
437+
(
438+
"create_users",
439+
"CREATE TABLE users (id INTEGER PRIMARY KEY);",
440+
),
441+
(
442+
"create_posts",
443+
"CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);",
444+
),
445+
(
446+
"add_index",
447+
"CREATE INDEX idx_posts_user ON posts(user_id);",
448+
),
449+
])
450+
.await;
451+
452+
db.run_migrations(&migrator).await.unwrap();
453+
454+
// Verify all migrations applied
455+
let (count,): (i64,) = sqlx::query_as(
456+
"SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_sqlx_%'",
457+
)
458+
.fetch_one(db.read_pool().unwrap())
459+
.await
460+
.unwrap();
461+
462+
assert_eq!(count, 3, "should have 2 tables + 1 index");
463+
464+
db.remove().await.unwrap();
465+
}
466+
467+
#[tokio::test]
468+
async fn test_run_migrations_idempotent() {
469+
let path = std::env::current_dir()
470+
.unwrap()
471+
.join("test_migrations_idempotent.db");
472+
let db = SqliteDatabase::connect(&path, None).await.unwrap();
473+
474+
let (_dir, migrator) = create_migrations(&[(
475+
"create_items",
476+
"CREATE TABLE items (id INTEGER PRIMARY KEY);",
477+
)])
478+
.await;
479+
480+
// Run twice - second should be no-op
481+
db.run_migrations(&migrator).await.unwrap();
482+
db.run_migrations(&migrator).await.unwrap();
483+
484+
// Verify table exists (no duplicate error)
485+
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sqlite_master WHERE name = 'items'")
486+
.fetch_one(db.read_pool().unwrap())
487+
.await
488+
.unwrap();
489+
490+
assert_eq!(count, 1);
491+
492+
db.remove().await.unwrap();
493+
}
494+
495+
#[tokio::test]
496+
async fn test_run_migrations_tracks_in_sqlx_table() {
497+
let path = std::env::current_dir()
498+
.unwrap()
499+
.join("test_migrations_tracking.db");
500+
let db = SqliteDatabase::connect(&path, None).await.unwrap();
501+
502+
let (_dir, migrator) = create_migrations(&[
503+
("first", "CREATE TABLE t1 (id INTEGER);"),
504+
("second", "CREATE TABLE t2 (id INTEGER);"),
505+
])
506+
.await;
507+
508+
db.run_migrations(&migrator).await.unwrap();
509+
510+
// Verify _sqlx_migrations table has 2 records
511+
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
512+
.fetch_one(db.read_pool().unwrap())
513+
.await
514+
.unwrap();
515+
516+
assert_eq!(count, 2, "should track 2 applied migrations");
517+
518+
db.remove().await.unwrap();
519+
}
520+
521+
#[tokio::test]
522+
async fn test_run_migrations_on_closed_db_errors() {
523+
let path = std::env::current_dir()
524+
.unwrap()
525+
.join("test_migrations_closed.db");
526+
let db = SqliteDatabase::connect(&path, None).await.unwrap();
527+
let db_ref = Arc::clone(&db);
528+
529+
db.close().await.unwrap();
530+
531+
let (_dir, migrator) = create_migrations(&[("noop", "SELECT 1;")]).await;
532+
let result = db_ref.run_migrations(&migrator).await;
533+
534+
assert!(result.is_err());
535+
assert!(matches!(result.unwrap_err(), Error::DatabaseClosed));
536+
537+
let _ = std::fs::remove_file(&path);
538+
}
539+
540+
#[tokio::test]
541+
async fn test_run_migrations_initializes_wal() {
542+
let path = std::env::current_dir()
543+
.unwrap()
544+
.join("test_migrations_wal.db");
545+
let db = SqliteDatabase::connect(&path, None).await.unwrap();
546+
547+
let (_dir, migrator) = create_migrations(&[("init", "CREATE TABLE t (id INTEGER);")]).await;
548+
db.run_migrations(&migrator).await.unwrap();
549+
550+
// Check WAL mode is set
551+
let (mode,): (String,) = sqlx::query_as("PRAGMA journal_mode")
552+
.fetch_one(db.read_pool().unwrap())
553+
.await
554+
.unwrap();
555+
556+
assert_eq!(mode.to_lowercase(), "wal");
557+
558+
db.remove().await.unwrap();
559+
}

guest-js/index.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
55
import { mockIPC, clearMocks } from '@tauri-apps/api/mocks'
6-
import Database from './index'
6+
import Database, { MigrationEvent } from './index'
77

88
let lastCmd = ''
99
let lastArgs: Record<string, unknown> = {}
@@ -81,3 +81,35 @@ describe('Database commands', () => {
8181
await expect(Database.get('t.db').execute('SELECT 1', [])).rejects.toThrow('Database error')
8282
})
8383
})
84+
85+
describe('MigrationEvent type', () => {
86+
it('accepts running status', () => {
87+
const event: MigrationEvent = {
88+
dbPath: 'test.db',
89+
status: 'running',
90+
}
91+
expect(event.status).toBe('running')
92+
expect(event.migrationCount).toBeUndefined()
93+
expect(event.error).toBeUndefined()
94+
})
95+
96+
it('accepts completed status with migrationCount', () => {
97+
const event: MigrationEvent = {
98+
dbPath: 'test.db',
99+
status: 'completed',
100+
migrationCount: 3,
101+
}
102+
expect(event.status).toBe('completed')
103+
expect(event.migrationCount).toBe(3)
104+
})
105+
106+
it('accepts failed status with error', () => {
107+
const event: MigrationEvent = {
108+
dbPath: 'test.db',
109+
status: 'failed',
110+
error: 'Migration failed: syntax error',
111+
}
112+
expect(event.status).toBe('failed')
113+
expect(event.error).toBe('Migration failed: syntax error')
114+
})
115+
})

guest-js/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,44 @@ export interface CustomConfig {
4848
idleTimeoutSecs?: number
4949
}
5050

51+
/**
52+
* Event payload emitted during database migration operations.
53+
*
54+
* Listen for these events to track migration progress:
55+
*
56+
* @example
57+
* ```ts
58+
* import { listen } from '@tauri-apps/api/event'
59+
* import type { MigrationEvent } from '@silvermine/tauri-plugin-sqlite'
60+
*
61+
* await listen<MigrationEvent>('sqlite:migration', (event) => {
62+
* const { dbPath, status, migrationCount, error } = event.payload
63+
*
64+
* switch (status) {
65+
* case 'running':
66+
* console.log(`Running migrations for ${dbPath}`)
67+
* break
68+
* case 'completed':
69+
* console.log(`Completed ${migrationCount} migrations for ${dbPath}`)
70+
* break
71+
* case 'failed':
72+
* console.error(`Migration failed for ${dbPath}: ${error}`)
73+
* break
74+
* }
75+
* })
76+
* ```
77+
*/
78+
export interface MigrationEvent {
79+
/** Database path (relative, as registered with the plugin) */
80+
dbPath: string
81+
/** Status: "running", "completed", "failed" */
82+
status: 'running' | 'completed' | 'failed'
83+
/** Number of migrations in the migrator (on "completed") */
84+
migrationCount?: number
85+
/** Error message (on "failed") */
86+
error?: string
87+
}
88+
5189
/**
5290
* **Database**
5391
*

0 commit comments

Comments
 (0)