refactor: convert to bun-workspaces monorepo (desktop/ + www/)

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
+443
View File
@@ -0,0 +1,443 @@
use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Configuration for establishing a database connection.
///
/// Fields map to connection parameters. For SQLite, `host` stores the
/// file path and `port` is always `None`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DbConfig {
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub password: Option<String>,
pub database: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
#[serde(default)]
pub ssh_host: Option<String>,
#[serde(default)]
pub ssh_port: Option<i64>,
#[serde(default)]
pub ssh_user: Option<String>,
#[serde(default)]
pub ssh_auth_method: Option<String>,
#[serde(default)]
pub ssh_password: Option<String>,
#[serde(default)]
pub ssh_private_key_path: Option<String>,
#[serde(default)]
pub ssh_passphrase: Option<String>,
}
impl DbConfig {
/// Create a `DbConfig` for a SQLite database at `path`.
///
/// `host` is set to the file path; all other optional fields are `None`.
pub fn sqlite(path: &str) -> Self {
Self {
db_type: "SQLite".into(),
host: path.into(),
port: None,
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
}
}
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
let host = self.ssh_host.clone()?;
if host.is_empty() {
return None;
}
Some(crate::models::SshConfig {
host,
port: self.ssh_port.unwrap_or(22) as u16,
user: self.ssh_user.clone().unwrap_or_default(),
auth_method: self
.ssh_auth_method
.clone()
.unwrap_or_else(|| "password".to_string()),
password: self.ssh_password.clone(),
private_key_path: self.ssh_private_key_path.clone(),
passphrase: self.ssh_passphrase.clone(),
})
}
}
/// A handle to an active database connection.
///
/// Supports `Sqlite` (synchronous via `rusqlite`), `Postgresql` (async via
/// `tokio-postgres`), and `MySql` (async via `sqlx`). Redis has no DB-viewer
/// support. Eviction relies on each variant's `Drop`: `MySqlPool` closes its
/// connections when dropped (mirroring `Postgresql`'s `JoinHandle` abort).
#[derive(Debug)]
pub enum DbHandle {
/// A synchronous SQLite connection via `rusqlite`.
Sqlite(rusqlite::Connection),
/// An asynchronous PostgreSQL connection via `tokio-postgres`.
/// Stores the client handle and the background connection task.
Postgresql(tokio_postgres::Client, tokio::task::JoinHandle<()>),
/// An asynchronous MySQL connection pool via `sqlx`.
MySql(sqlx::MySqlPool),
}
/// Internal entry stored in the pool manager.
///
/// Tracks the database handle and the last time it was accessed for LRU
/// eviction.
#[derive(Debug)]
pub(crate) struct DbPoolEntry {
pub(crate) handle: DbHandle,
pub(crate) last_accessed: Instant,
}
/// A connection pool manager with LRU eviction.
///
/// Manages a set of active database handles keyed by a user-defined
/// identifier. When the number of registered pools exceeds `max_pools`,
/// the least-recently-used entry (i.e. the pool whose handle was accessed
/// furthest in the past) is evicted.
///
/// Default `max_pools` is 5.
pub struct ConnectionPoolManager {
pools: indexmap::IndexMap<String, DbPoolEntry>,
max_pools: usize,
/// Invoked with the id of every pool that gets evicted (LRU overflow in
/// `register` or shrinkage in `set_max_pools`). Lets callers free
/// associated resources (e.g. SSH tunnels).
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
}
impl ConnectionPoolManager {
/// Create a new manager with a maximum of 5 pools.
pub fn new() -> Self {
Self {
pools: indexmap::IndexMap::new(),
max_pools: 5,
on_evict: None,
}
}
/// Register a callback invoked with the id of every evicted pool.
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
self.on_evict = Some(cb);
}
/// Set the maximum number of pools before LRU eviction kicks in.
///
/// If the current pool count exceeds the new maximum, the oldest
/// entries are evicted immediately.
pub fn set_max_pools(&mut self, max: usize) {
self.max_pools = max;
while self.pools.len() > self.max_pools {
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
if let Some(cb) = &self.on_evict {
cb(&evicted_id);
}
}
}
}
/// Register a new database handle under `id`.
///
/// * If `id` already exists the old entry is removed first.
/// * The new entry is inserted as the most-recently-used.
/// * If the total pool count exceeds `max_pools` the least-recently-used
/// (oldest) entry is evicted.
pub fn register(&mut self, id: &str, handle: DbHandle) {
// Remove existing entry if present
self.pools.shift_remove(id);
let entry = DbPoolEntry {
handle,
last_accessed: Instant::now(),
};
self.pools.insert(id.to_string(), entry);
// LRU eviction: remove oldest (front) entries until within capacity
while self.pools.len() > self.max_pools {
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
if let Some(cb) = &self.on_evict {
cb(&evicted_id);
}
}
}
}
/// Get a mutable reference to the handle for `id`, or `None`.
///
/// Updates the last-accessed timestamp and re-orders the entry to
/// mark it as most-recently-used.
pub fn get(&mut self, id: &str) -> Option<&mut DbHandle> {
if let Some((key, mut entry)) = self.pools.shift_remove_entry(id) {
entry.last_accessed = Instant::now();
self.pools.insert(key, entry);
// The newly inserted entry is at the end (MRU position)
self.pools.last_mut().map(|(_, e)| &mut e.handle)
} else {
None
}
}
/// Remove the pool with `id` from the manager.
pub fn remove(&mut self, id: &str) {
self.pools.shift_remove(id);
}
/// Return a reference to the underlying pool map.
pub(crate) fn pools(&self) -> &indexmap::IndexMap<String, DbPoolEntry> {
&self.pools
}
/// Return `true` if a pool with `id` is registered.
pub fn contains(&self, id: &str) -> bool {
self.pools.contains_key(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// DbConfig tests
// ------------------------------------------------------------------
#[test]
fn create_pg_pool_with_minimal_config() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "pg.example.com".into(),
port: Some(5432),
username: Some("admin".into()),
password: Some("secret".into()),
database: Some("mydb".into()),
..Default::default()
};
assert_eq!(cfg.db_type, "PostgreSQL");
assert_eq!(cfg.host, "pg.example.com");
assert_eq!(cfg.port, Some(5432));
assert_eq!(cfg.username.as_deref(), Some("admin"));
assert_eq!(cfg.password.as_deref(), Some("secret"));
assert_eq!(cfg.database.as_deref(), Some("mydb"));
assert!(cfg.ssl_mode.is_none());
}
#[test]
fn db_config_for_sqlite_has_no_port() {
let cfg = DbConfig::sqlite("/tmp/test.db");
assert_eq!(cfg.db_type, "SQLite");
assert_eq!(cfg.host, "/tmp/test.db");
assert!(cfg.port.is_none());
assert!(cfg.username.is_none());
assert!(cfg.database.is_none());
}
#[test]
fn db_config_ssh_config_is_none_when_no_host() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "h".into(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
};
assert!(cfg.ssh_config().is_none());
}
#[test]
fn db_config_ssh_config_builds_from_flat_fields() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "db".into(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: Some("jump".into()),
ssh_port: Some(2222),
ssh_user: Some("u".into()),
ssh_auth_method: Some("password".into()),
ssh_password: Some("pw".into()),
ssh_private_key_path: None,
ssh_passphrase: None,
};
let s = cfg.ssh_config().expect("ssh config present");
assert_eq!(s.host, "jump");
assert_eq!(s.port, 2222);
assert_eq!(s.user, "u");
assert_eq!(s.auth_method, "password");
assert_eq!(s.password.as_deref(), Some("pw"));
}
// ------------------------------------------------------------------
// ConnectionPoolManager tests
// ------------------------------------------------------------------
#[test]
fn pool_manager_starts_empty() {
let manager = ConnectionPoolManager::new();
assert_eq!(manager.pools().len(), 0);
}
#[test]
fn pool_manager_register_and_evict() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(2);
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
// Register A, B, then C with max=2 -- A should be evicted (LRU)
manager.register("a", DbHandle::Sqlite(conn_a));
manager.register("b", DbHandle::Sqlite(conn_b));
manager.register("c", DbHandle::Sqlite(conn_c));
assert_eq!(manager.pools().len(), 2);
assert!(!manager.contains("a"), "'a' should have been evicted (LRU)");
assert!(manager.contains("b"));
assert!(manager.contains("c"));
}
#[test]
fn pool_manager_remove_closes_pool() {
let mut manager = ConnectionPoolManager::new();
let conn = rusqlite::Connection::open_in_memory().unwrap();
manager.register("tmp", DbHandle::Sqlite(conn));
assert!(manager.contains("tmp"));
manager.remove("tmp");
assert!(!manager.contains("tmp"));
assert_eq!(manager.pools().len(), 0);
}
#[test]
fn pool_manager_get_updates_access_time() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(3);
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
manager.register("a", DbHandle::Sqlite(conn_a));
manager.register("b", DbHandle::Sqlite(conn_b));
manager.register("c", DbHandle::Sqlite(conn_c));
// Access "a" -- makes it MRU
let _handle = manager.get("a").unwrap();
// Register "d" with max=3 -- "b" (now LRU) should be evicted, not "a"
let conn_d = rusqlite::Connection::open_in_memory().unwrap();
manager.register("d", DbHandle::Sqlite(conn_d));
assert_eq!(manager.pools().len(), 3);
assert!(
manager.contains("a"),
"'a' was recently accessed, should survive"
);
assert!(!manager.contains("b"), "'b' is LRU and should be evicted");
assert!(manager.contains("c"));
assert!(manager.contains("d"));
}
#[test]
fn pool_invokes_on_evict_with_evicted_id() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
let evicted_cb = evicted.clone();
manager.set_on_evict(Box::new(move |id: &str| {
evicted_cb.lock().unwrap().push(id.to_string());
}));
manager.register(
"a",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
manager.register(
"b",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[test]
fn pool_invokes_on_evict_on_max_pools_shrink() {
let mut manager = ConnectionPoolManager::new();
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
let evicted_cb = evicted.clone();
manager.set_on_evict(Box::new(move |id: &str| {
evicted_cb.lock().unwrap().push(id.to_string());
}));
manager.register(
"a",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
manager.register(
"b",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
// Shrinking max_pools below the current count evicts oldest first.
manager.set_max_pools(1);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[tokio::test]
async fn mysql_handle_can_be_registered_and_evicted() {
// Lazy pool: parses the URL without connecting (no network touch).
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://__gridline_test__:3306/__none__")
.expect("lazy pool parses url without connecting");
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
manager.register("mysql-conn", DbHandle::MySql(pool));
assert!(matches!(
manager.get("mysql-conn"),
Some(DbHandle::MySql(_))
));
// Registering a second connection evicts the first (max_pools=1).
let sqlite = rusqlite::Connection::open_in_memory().unwrap();
manager.register("sqlite-conn", DbHandle::Sqlite(sqlite));
assert!(manager.get("mysql-conn").is_none());
assert!(matches!(
manager.get("sqlite-conn"),
Some(DbHandle::Sqlite(_))
));
}
}