snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
28
crates/common/yaak-database/Cargo.toml
Normal file
28
crates/common/yaak-database/Cargo.toml
Normal 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"] }
|
||||
24
crates/common/yaak-database/src/connection_or_tx.rs
Normal file
24
crates/common/yaak-database/src/connection_or_tx.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
194
crates/common/yaak-database/src/db_context.rs
Normal file
194
crates/common/yaak-database/src/db_context.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
37
crates/common/yaak-database/src/error.rs
Normal file
37
crates/common/yaak-database/src/error.rs
Normal 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>;
|
||||
26
crates/common/yaak-database/src/lib.rs
Normal file
26
crates/common/yaak-database/src/lib.rs
Normal 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;
|
||||
79
crates/common/yaak-database/src/migrate.rs
Normal file
79
crates/common/yaak-database/src/migrate.rs
Normal 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(())
|
||||
}
|
||||
81
crates/common/yaak-database/src/pool.rs
Normal file
81
crates/common/yaak-database/src/pool.rs
Normal 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::*;
|
||||
36
crates/common/yaak-database/src/traits.rs
Normal file
36
crates/common/yaak-database/src/traits.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
31
crates/common/yaak-database/src/update_source.rs
Normal file
31
crates/common/yaak-database/src/update_source.rs
Normal 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,
|
||||
}
|
||||
20
crates/common/yaak-database/src/util.rs
Normal file
20
crates/common/yaak-database/src/util.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user