diff --git a/Cargo.lock b/Cargo.lock index a3f2ff1c..6aafaa39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10383,6 +10383,21 @@ dependencies = [ "yaak-models", ] +[[package]] +name = "yaak-database" +version = "0.1.0" +dependencies = [ + "include_dir", + "log 0.4.29", + "nanoid", + "r2d2", + "r2d2_sqlite", + "rusqlite", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "yaak-fonts" version = "0.1.0" @@ -10523,6 +10538,7 @@ dependencies = [ "thiserror 2.0.17", "ts-rs", "yaak-core", + "yaak-database", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index df5cb002..0315692c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "crates/yaak", + # Common/foundation crates + "crates/common/yaak-database", # Shared crates (no Tauri dependency) "crates/yaak-core", "crates/yaak-common", @@ -50,6 +52,9 @@ thiserror = "2.0.17" tokio = "1.48.0" ts-rs = "11.1.0" +# Internal crates - common/foundation +yaak-database = { path = "crates/common/yaak-database" } + # Internal crates - shared yaak-core = { path = "crates/yaak-core" } yaak = { path = "crates/yaak" } diff --git a/crates-tauri/yaak-mac-window/src/lib.rs b/crates-tauri/yaak-mac-window/src/lib.rs index 9c629eab..64335822 100644 --- a/crates-tauri/yaak-mac-window/src/lib.rs +++ b/crates-tauri/yaak-mac-window/src/lib.rs @@ -25,6 +25,7 @@ pub(crate) struct PluginState { } pub fn init() -> TauriPlugin { + #[allow(unused_mut)] let mut builder = plugin::Builder::new("yaak-mac-window") .setup(move |app, _| { app.manage(PluginState { native_titlebar: AtomicBool::new(false) }); diff --git a/crates/common/yaak-database/Cargo.toml b/crates/common/yaak-database/Cargo.toml new file mode 100644 index 00000000..749dc5f2 --- /dev/null +++ b/crates/common/yaak-database/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "yaak-database" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +include_dir = "0.7" +log = { workspace = true } +nanoid = "0.4.0" +r2d2 = "0.8.10" +r2d2_sqlite = { version = "0.25.0" } +rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/common/yaak-database/src/connection_or_tx.rs b/crates/common/yaak-database/src/connection_or_tx.rs new file mode 100644 index 00000000..bad2e1dc --- /dev/null +++ b/crates/common/yaak-database/src/connection_or_tx.rs @@ -0,0 +1,25 @@ +use r2d2::PooledConnection; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::{Connection, Statement, ToSql, Transaction}; + +pub enum ConnectionOrTx<'a> { + Connection(PooledConnection), + Transaction(&'a Transaction<'a>), +} + +impl<'a> ConnectionOrTx<'a> { + pub fn resolve(&self) -> &Connection { + match self { + ConnectionOrTx::Connection(c) => c, + ConnectionOrTx::Transaction(c) => c, + } + } + + pub fn prepare(&self, sql: &str) -> rusqlite::Result> { + self.resolve().prepare(sql) + } + + pub fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> rusqlite::Result { + self.resolve().execute(sql, params) + } +} diff --git a/crates/common/yaak-database/src/error.rs b/crates/common/yaak-database/src/error.rs new file mode 100644 index 00000000..eff15c12 --- /dev/null +++ b/crates/common/yaak-database/src/error.rs @@ -0,0 +1,37 @@ +use serde::{Serialize, Serializer}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("SQL error: {0}")] + SqlError(#[from] rusqlite::Error), + + #[error("SQL Pool error: {0}")] + SqlPoolError(#[from] r2d2::Error), + + #[error("Database error: {0}")] + Database(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("Model not found: {0}")] + ModelNotFound(String), + + #[error("DB Migration Failed: {0}")] + MigrationError(String), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +pub type Result = std::result::Result; diff --git a/crates/common/yaak-database/src/lib.rs b/crates/common/yaak-database/src/lib.rs new file mode 100644 index 00000000..9e1aa0ed --- /dev/null +++ b/crates/common/yaak-database/src/lib.rs @@ -0,0 +1,15 @@ +pub mod connection_or_tx; +pub mod error; +pub mod migrate; +pub mod util; + +// Re-export key types for convenience +pub use connection_or_tx::ConnectionOrTx; +pub use error::{Error, Result}; +pub use migrate::run_migrations; +pub use util::{generate_id, generate_id_of_length, generate_prefixed_id}; + +// Re-export pool types that consumers will need +pub use r2d2; +pub use r2d2_sqlite; +pub use rusqlite; diff --git a/crates/common/yaak-database/src/migrate.rs b/crates/common/yaak-database/src/migrate.rs new file mode 100644 index 00000000..c81b0c21 --- /dev/null +++ b/crates/common/yaak-database/src/migrate.rs @@ -0,0 +1,81 @@ +use crate::error::Result; +use include_dir::Dir; +use log::{debug, info}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::{OptionalExtension, params}; + +const TRACKING_TABLE: &str = "_sqlx_migrations"; + +/// Run SQL migrations from an embedded directory. +/// +/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`). +/// Applied migrations are tracked in `_sqlx_migrations`. +pub fn run_migrations(pool: &Pool, dir: &Dir<'_>) -> Result<()> { + info!("Running migrations"); + + // Create tracking table + pool.get()?.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {TRACKING_TABLE} ( + version TEXT PRIMARY KEY, + description TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + )" + ), + [], + )?; + + // Read and sort all .sql files + let mut entries: Vec<_> = dir + .entries() + .iter() + .filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false)) + .collect(); + + entries.sort_by_key(|e| e.path()); + + let mut ran_migrations = 0; + for entry in &entries { + let filename = entry.path().file_name().unwrap().to_str().unwrap(); + let version = filename.split('_').next().unwrap(); + + // Check if already applied + let already_applied: Option = pool + .get()? + .query_row( + &format!("SELECT 1 FROM {TRACKING_TABLE} WHERE version = ?"), + [version], + |r| r.get(0), + ) + .optional()?; + + if already_applied.is_some() { + debug!("Skipping already applied migration: {}", filename); + continue; + } + + let sql = + entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file"); + + info!("Applying migration: {}", filename); + let conn = pool.get()?; + conn.execute_batch(sql)?; + + // Record migration + conn.execute( + &format!("INSERT INTO {TRACKING_TABLE} (version, description) VALUES (?, ?)"), + params![version, filename], + )?; + + ran_migrations += 1; + } + + if ran_migrations == 0 { + info!("No migrations to run"); + } else { + info!("Ran {} migration(s)", ran_migrations); + } + + Ok(()) +} diff --git a/crates/common/yaak-database/src/util.rs b/crates/common/yaak-database/src/util.rs new file mode 100644 index 00000000..a9c84468 --- /dev/null +++ b/crates/common/yaak-database/src/util.rs @@ -0,0 +1,20 @@ +use nanoid::nanoid; + +pub fn generate_prefixed_id(prefix: &str) -> String { + format!("{prefix}_{}", generate_id()) +} + +pub fn generate_id() -> String { + generate_id_of_length(10) +} + +pub fn generate_id_of_length(n: usize) -> String { + let alphabet: [char; 57] = [ + '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', + 'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', + 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z', + ]; + + nanoid!(n, &alphabet) +} diff --git a/crates/yaak-models/Cargo.toml b/crates/yaak-models/Cargo.toml index 95b32df4..f28f0d56 100644 --- a/crates/yaak-models/Cargo.toml +++ b/crates/yaak-models/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] chrono = { version = "0.4.38", features = ["serde"] } +yaak-database = { workspace = true } hex = { workspace = true } include_dir = "0.7" log = { workspace = true } diff --git a/crates/yaak-models/src/connection_or_tx.rs b/crates/yaak-models/src/connection_or_tx.rs index 08422fab..1fed3412 100644 --- a/crates/yaak-models/src/connection_or_tx.rs +++ b/crates/yaak-models/src/connection_or_tx.rs @@ -1,25 +1 @@ -use r2d2::PooledConnection; -use r2d2_sqlite::SqliteConnectionManager; -use rusqlite::{Connection, Statement, ToSql, Transaction}; - -pub enum ConnectionOrTx<'a> { - Connection(PooledConnection), - Transaction(&'a Transaction<'a>), -} - -impl<'a> ConnectionOrTx<'a> { - pub(crate) fn resolve(&self) -> &Connection { - match self { - ConnectionOrTx::Connection(c) => c, - ConnectionOrTx::Transaction(c) => c, - } - } - - pub(crate) fn prepare(&self, sql: &str) -> rusqlite::Result> { - self.resolve().prepare(sql) - } - - pub(crate) fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> rusqlite::Result { - self.resolve().execute(sql, params) - } -} +pub use yaak_database::connection_or_tx::ConnectionOrTx; diff --git a/crates/yaak-models/src/util.rs b/crates/yaak-models/src/util.rs index 583425e4..0b026f11 100644 --- a/crates/yaak-models/src/util.rs +++ b/crates/yaak-models/src/util.rs @@ -5,30 +5,12 @@ use crate::models::{ Workspace, WorkspaceIden, }; use chrono::{NaiveDateTime, Utc}; -use nanoid::nanoid; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use ts_rs::TS; use yaak_core::WorkspaceContext; -pub fn generate_prefixed_id(prefix: &str) -> String { - format!("{prefix}_{}", generate_id()) -} - -pub fn generate_id() -> String { - generate_id_of_length(10) -} - -pub fn generate_id_of_length(n: usize) -> String { - let alphabet: [char; 57] = [ - '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', - 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', - 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', - 'X', 'Y', 'Z', - ]; - - nanoid!(n, &alphabet) -} +pub use yaak_database::{generate_id, generate_id_of_length, generate_prefixed_id}; #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")]