snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
[package]
name = "yaak-database"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
# nanoid pulls getrandom, which needs to be told how to reach the browser's
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
uuid = { version = "1", features = ["js"] }

View File

@@ -0,0 +1,24 @@
use crate::pool::SqliteConn;
use rusqlite::{Connection, Statement, ToSql, Transaction};
pub enum ConnectionOrTx<'a> {
Connection(SqliteConn),
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<Statement<'_>> {
self.resolve().prepare(sql)
}
pub fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> rusqlite::Result<usize> {
self.resolve().execute(sql, params)
}
}

View File

@@ -0,0 +1,194 @@
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Error::ModelNotFound;
use crate::error::Result;
use crate::traits::UpsertModelInfo;
use crate::update_source::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
};
use sea_query_rusqlite::RusqliteBinder;
use std::fmt::Debug;
pub struct DbContext<'a> {
conn: ConnectionOrTx<'a>,
}
impl<'a> DbContext<'a> {
pub fn new(conn: ConnectionOrTx<'a>) -> Self {
Self { conn }
}
pub fn conn(&self) -> &ConnectionOrTx<'a> {
&self.conn
}
pub fn find_one<M>(
&self,
col: impl IntoColumnRef + IntoIden + Clone,
value: impl Into<SimpleExpr> + Debug,
) -> Result<M>
where
M: UpsertModelInfo,
{
let value_debug = format!("{:?}", value);
let value_expr = value.into();
let (sql, params) = Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col.clone()).eq(value_expr))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
match stmt.query_row(&*params.as_params(), M::from_row) {
Ok(result) => Ok(result),
Err(rusqlite::Error::QueryReturnedNoRows) => Err(ModelNotFound(format!(
r#"table "{}" {} == {}"#,
M::table_name().into_iden().to_string(),
col.into_iden().to_string(),
value_debug
))),
Err(e) => Err(crate::error::Error::SqlError(e)),
}
}
pub fn find_optional<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Option<M>
where
M: UpsertModelInfo,
{
let (sql, params) = Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col).eq(value))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
stmt.query_row(&*params.as_params(), M::from_row).ok()
}
pub fn find_all<M>(&self) -> Result<Vec<M>>
where
M: UpsertModelInfo,
{
let (order_by_col, order_by_dir) = M::order_by();
let (sql, params) = Query::select()
.from(M::table_name())
.column(Asterisk)
.order_by(order_by_col, order_by_dir)
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), M::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
pub fn find_many<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
limit: Option<u64>,
) -> Result<Vec<M>>
where
M: UpsertModelInfo,
{
let (order_by_col, order_by_dir) = M::order_by();
let (sql, params) = if let Some(limit) = limit {
Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col).eq(value))
.limit(limit)
.order_by(order_by_col, order_by_dir)
.build_rusqlite(SqliteQueryBuilder)
} else {
Query::select()
.from(M::table_name())
.column(Asterisk)
.cond_where(Expr::col(col).eq(value))
.order_by(order_by_col, order_by_dir)
.build_rusqlite(SqliteQueryBuilder)
};
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
let items = stmt.query_map(&*params.as_params(), M::from_row)?;
Ok(items.map(|v| v.unwrap()).collect())
}
/// Upsert a model. Returns `(model, created)` where `created` is true if a new row was inserted.
pub fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<(M, bool)>
where
M: UpsertModelInfo + Clone,
{
let id_iden = M::id_column().into_iden();
let id_val = model.get_id();
let other_values = model.clone().insert_values(source)?;
let mut column_vec = vec![id_iden.clone()];
let mut value_vec =
vec![if id_val.is_empty() { M::generate_id().into() } else { id_val.into() }];
for (col, val) in other_values {
value_vec.push(val.into());
column_vec.push(col.into_iden());
}
let on_conflict =
OnConflict::column(id_iden).update_columns(M::update_columns()).to_owned();
let (sql, params) = Query::insert()
.into_table(M::table_name())
.columns(column_vec)
.values_panic(value_vec)
.on_conflict(on_conflict)
.returning(Query::returning().exprs(vec![
Expr::col(Asterisk),
Expr::expr(Func::cust("last_insert_rowid")),
Expr::col("rowid"),
]))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.resolve().prepare(sql.as_str())?;
let (m, created): (M, bool) = stmt.query_row(&*params.as_params(), |row| {
M::from_row(row).and_then(|m| {
let rowid: i64 = row.get("rowid")?;
let last_rowid: i64 = row.get("last_insert_rowid()")?;
Ok((m, rowid == last_rowid))
})
})?;
Ok((m, created))
}
/// Bulk-delete all rows matching a column value with a single statement.
/// Returns the number of rows deleted.
pub fn delete_many<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Result<usize>
where
M: UpsertModelInfo,
{
let (sql, params) = Query::delete()
.from_table(M::table_name())
.cond_where(Expr::col(col).eq(value))
.build_rusqlite(SqliteQueryBuilder);
let count = self.conn.execute(sql.as_str(), &*params.as_params())?;
Ok(count)
}
/// Delete a model by its ID. Returns the number of rows deleted.
pub fn delete<M>(&self, m: &M) -> Result<usize>
where
M: UpsertModelInfo,
{
let (sql, params) = Query::delete()
.from_table(M::table_name())
.cond_where(Expr::col(M::id_column().into_iden()).eq(m.get_id()))
.build_rusqlite(SqliteQueryBuilder);
let count = self.conn.execute(sql.as_str(), &*params.as_params())?;
Ok(count)
}
}

View File

@@ -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] crate::pool::PoolError),
#[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<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,26 @@
pub mod connection_or_tx;
pub mod db_context;
pub mod error;
pub mod migrate;
pub mod pool;
pub mod traits;
pub mod update_source;
pub mod util;
// Re-export key types for convenience
pub use connection_or_tx::ConnectionOrTx;
pub use db_context::DbContext;
pub use error::{Error, Result};
pub use migrate::run_migrations;
pub use pool::{PoolError, SqliteConn, SqlitePool};
pub use traits::{UpsertModelInfo, upsert_date};
pub use update_source::{ModelChangeEvent, UpdateSource};
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
// Re-export types that consumers will need
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2;
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2_sqlite;
pub use rusqlite;
pub use sea_query;

View File

@@ -0,0 +1,79 @@
use crate::error::Result;
use crate::pool::SqlitePool;
use include_dir::Dir;
use log::{debug, info};
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: &SqlitePool, 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<i64> = 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(())
}

View File

@@ -0,0 +1,81 @@
//! Where connections come from.
//!
//! Every query in the model layer asks a pool for a connection, uses it, and
//! hands it back. That is the whole contract, and it is the one place the
//! desktop and the browser genuinely differ: the desktop has threads and wants
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
//! connection is exactly enough. Everything above this module is identical on
//! both.
//!
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
//! that already builds pools changes. On wasm it is one connection that every
//! `get()` hands out a shared handle to.
//!
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
//! compile-time guard against nesting a transaction on one connection, and it
//! is what would have forced the wasm pool to lend its connection exclusively.
//! The model layer nests connections freely — a helper that already holds one
//! calls another that asks for its own — so an exclusive lend would panic on
//! the second ask. Sharing the handle instead makes nested *reads* work the way
//! they do on the desktop; nested *write transactions* fail on both, only
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
//! call waits for the one writer connection, which the outer call holds, and
//! times out).
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use r2d2_sqlite::SqliteConnectionManager;
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
pub type PoolError = r2d2::Error;
}
#[cfg(target_arch = "wasm32")]
mod imp {
use rusqlite::Connection;
use std::ops::Deref;
use std::rc::Rc;
/// One connection, shared by everyone who asks.
///
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
/// thread here to be honest about.
#[derive(Clone, Debug)]
pub struct SqlitePool {
conn: Rc<Connection>,
}
impl SqlitePool {
pub fn single(conn: Connection) -> Self {
Self { conn: Rc::new(conn) }
}
/// Another handle to the connection. Cannot fail; the `Result` keeps
/// the signature identical to r2d2's so callers are written once.
pub fn get(&self) -> Result<SqliteConn, PoolError> {
Ok(SqliteConn(self.conn.clone()))
}
}
/// The error a `get()` would return if it could. It can't, so this has no
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
/// targets.
#[derive(Debug, thiserror::Error)]
pub enum PoolError {}
#[derive(Debug)]
pub struct SqliteConn(Rc<Connection>);
impl Deref for SqliteConn {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.0
}
}
}
pub use imp::*;

View File

@@ -0,0 +1,36 @@
use crate::error::Result;
use crate::update_source::UpdateSource;
use chrono::{NaiveDateTime, Utc};
use rusqlite::Row;
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr};
pub trait UpsertModelInfo {
fn table_name() -> impl IntoTableRef + IntoIden;
fn id_column() -> impl IntoIden + Eq + Clone;
fn generate_id() -> String;
fn order_by() -> (impl IntoColumnRef, Order);
fn get_id(&self) -> String;
fn insert_values(
self,
source: &UpdateSource,
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>>;
fn update_columns() -> Vec<impl IntoIden>;
fn from_row(row: &Row) -> rusqlite::Result<Self>
where
Self: Sized;
}
/// Generate timestamps for upsert operations.
/// Sync and import operations preserve existing timestamps; other sources use current time.
pub fn upsert_date(update_source: &UpdateSource, dt: NaiveDateTime) -> SimpleExpr {
match update_source {
UpdateSource::Sync | UpdateSource::Import => {
if dt.and_utc().timestamp() == 0 {
Utc::now().naive_utc().into()
} else {
dt.into()
}
}
_ => Utc::now().naive_utc().into(),
}
}

View File

@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum UpdateSource {
Background,
Import,
Plugin,
Sync,
Window { label: String },
}
impl UpdateSource {
pub fn from_window_label(label: impl Into<String>) -> Self {
Self::Window { label: label.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ModelChangeEvent {
Upsert {
created: bool,
},
/// A delete for a workspace implies deletion of every model in that
/// workspace — children are bulk-deleted without their own change rows or
/// events, and consumers must prune the subtree themselves (the frontend
/// model store does this centrally).
Delete,
}

View File

@@ -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)
}