mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-14 22:01:57 +02:00
Route all database writes through one connection (#642)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ff7eebf3cd
commit
90cb578e26
@@ -1,9 +1,11 @@
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::util::generate_prefixed_id;
|
||||
use include_dir::{Dir, include_dir};
|
||||
use log::{debug, info};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use yaak_database::{SqliteConn, SqlitePool};
|
||||
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use std::ops::Deref;
|
||||
use yaak_database::{ConnectionOrTx, SqlitePool};
|
||||
|
||||
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
|
||||
|
||||
@@ -22,41 +24,64 @@ impl BodyChunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the blob database connection pool.
|
||||
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
|
||||
/// Manages the blob database: a reader pool and a single writer, for the
|
||||
/// same reason as [`crate::query_manager::QueryManager`].
|
||||
// Pools are internally synchronized — don't wrap them in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlobManager {
|
||||
pool: SqlitePool,
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
}
|
||||
|
||||
impl BlobManager {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
/// `writer` must be a pool with a single connection.
|
||||
pub fn new(readers: SqlitePool, writer: SqlitePool) -> Self {
|
||||
Self { readers, writer }
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> BlobContext {
|
||||
let conn = self.pool.get().expect("Failed to get blob DB connection from pool");
|
||||
BlobContext { conn }
|
||||
/// A read handle from the reader pool.
|
||||
pub fn connect(&self) -> BlobContext<'_> {
|
||||
let conn = self.readers.get().expect("Failed to get blob DB connection from pool");
|
||||
BlobContext { conn: ConnectionOrTx::Connection(conn) }
|
||||
}
|
||||
|
||||
/// Run `func` in a transaction on the writer connection.
|
||||
pub fn with_tx<T, E>(
|
||||
&self,
|
||||
func: impl FnOnce(&BlobWriter) -> std::result::Result<T, E>,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
.map_err(crate::error::Error::SqlError)?;
|
||||
let writer = BlobWriter { ctx: BlobContext { conn: ConnectionOrTx::Transaction(&tx) } };
|
||||
match func(&writer) {
|
||||
Ok(val) => {
|
||||
tx.commit().map_err(|e| {
|
||||
GenericError(format!("Failed to commit blob transaction {e:?}"))
|
||||
})?;
|
||||
Ok(val)
|
||||
}
|
||||
Err(e) => {
|
||||
tx.rollback().map_err(|e| {
|
||||
GenericError(format!("Failed to rollback blob transaction {e:?}"))
|
||||
})?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for blob database operations.
|
||||
pub struct BlobContext {
|
||||
conn: SqliteConn,
|
||||
/// Read handle for the blob database.
|
||||
pub struct BlobContext<'a> {
|
||||
conn: ConnectionOrTx<'a>,
|
||||
}
|
||||
|
||||
impl BlobContext {
|
||||
/// Insert a single chunk.
|
||||
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'a> BlobContext<'a> {
|
||||
/// Get all chunks for a body, ordered by chunk_index.
|
||||
pub fn get_chunks(&self, body_id: &str) -> Result<Vec<BodyChunk>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
@@ -87,25 +112,11 @@ impl BlobContext {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Delete all chunks for a body.
|
||||
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
|
||||
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all bodies for a response).
|
||||
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get total size of a body without loading data.
|
||||
impl BlobContext {
|
||||
/// Get total size of a body without loading data.
|
||||
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
|
||||
let size: i64 = self
|
||||
.conn
|
||||
.resolve()
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
@@ -119,6 +130,7 @@ impl BlobContext {
|
||||
pub fn body_exists(&self, body_id: &str) -> Result<bool> {
|
||||
let count: i64 = self
|
||||
.conn
|
||||
.resolve()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
@@ -129,6 +141,44 @@ impl BlobContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write handle for the blob database. Derefs to [`BlobContext`] for reads.
|
||||
pub struct BlobWriter<'a> {
|
||||
ctx: BlobContext<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for BlobWriter<'a> {
|
||||
type Target = BlobContext<'a>;
|
||||
|
||||
fn deref(&self) -> &BlobContext<'a> {
|
||||
&self.ctx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BlobWriter<'a> {
|
||||
/// Insert a single chunk.
|
||||
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks for a body.
|
||||
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
|
||||
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all
|
||||
/// bodies for a response).
|
||||
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run migrations for the blob database.
|
||||
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
info!("Running blob database migrations");
|
||||
@@ -196,28 +246,39 @@ pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
|
||||
fn create_test_pool() -> SqlitePool {
|
||||
fn create_test_manager() -> BlobManager {
|
||||
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
|
||||
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
|
||||
migrate_blob_db(&pool).unwrap();
|
||||
pool
|
||||
BlobManager::new(pool.clone(), pool)
|
||||
}
|
||||
|
||||
fn insert(manager: &BlobManager, chunks: &[BodyChunk]) {
|
||||
manager
|
||||
.with_tx(|b| {
|
||||
for c in chunks {
|
||||
b.insert_chunk(c)?;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_get_chunks() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec());
|
||||
let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec());
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 0, b"Hello, ".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"World!".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
ctx.insert_chunk(&chunk1).unwrap();
|
||||
ctx.insert_chunk(&chunk2).unwrap();
|
||||
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
let chunks = manager.connect().get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[0].chunk_index, 0);
|
||||
assert_eq!(chunks[0].data, b"Hello, ");
|
||||
@@ -227,18 +288,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_chunks_ordered_by_index() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
|
||||
// Insert out of order
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 2, b"C".to_vec()),
|
||||
BodyChunk::new(body_id, 0, b"A".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"B".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
let chunks = manager.connect().get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 3);
|
||||
assert_eq!(chunks[0].data, b"A");
|
||||
assert_eq!(chunks[1].data, b"B");
|
||||
@@ -247,89 +309,85 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_delete_chunks() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap();
|
||||
insert(&manager, &[BodyChunk::new(body_id, 0, b"data".to_vec())]);
|
||||
assert!(manager.connect().body_exists(body_id).unwrap());
|
||||
|
||||
assert!(ctx.body_exists(body_id).unwrap());
|
||||
|
||||
ctx.delete_chunks(body_id).unwrap();
|
||||
manager.with_tx(|b| b.delete_chunks(body_id)).unwrap();
|
||||
|
||||
let ctx = manager.connect();
|
||||
assert!(!ctx.body_exists(body_id).unwrap());
|
||||
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_chunks_like() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
// Insert chunks for same response but different body types
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new("rs_abc.request", 0, b"req".to_vec()),
|
||||
BodyChunk::new("rs_abc.response", 0, b"resp".to_vec()),
|
||||
BodyChunk::new("rs_other.request", 0, b"other".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
// Delete all bodies for rs_abc
|
||||
ctx.delete_chunks_like("rs_abc.%").unwrap();
|
||||
manager.with_tx(|b| b.delete_chunks_like("rs_abc.%")).unwrap();
|
||||
|
||||
// rs_abc bodies should be gone
|
||||
let ctx = manager.connect();
|
||||
assert!(!ctx.body_exists("rs_abc.request").unwrap());
|
||||
assert!(!ctx.body_exists("rs_abc.response").unwrap());
|
||||
|
||||
// rs_other should still exist
|
||||
assert!(ctx.body_exists("rs_other.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_body_size() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 0, b"Hello".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"World".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let size = ctx.get_body_size(body_id).unwrap();
|
||||
let size = manager.connect().get_body_size(body_id).unwrap();
|
||||
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_body_size_empty() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let size = ctx.get_body_size("nonexistent").unwrap();
|
||||
let manager = create_test_manager();
|
||||
let size = manager.connect().get_body_size("nonexistent").unwrap();
|
||||
assert_eq!(size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_exists() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
let manager = create_test_manager();
|
||||
assert!(!manager.connect().body_exists("rs_test.request").unwrap());
|
||||
|
||||
assert!(!ctx.body_exists("rs_test.request").unwrap());
|
||||
insert(&manager, &[BodyChunk::new("rs_test.request", 0, b"data".to_vec())]);
|
||||
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap();
|
||||
|
||||
assert!(ctx.body_exists("rs_test.request").unwrap());
|
||||
assert!(manager.connect().body_exists("rs_test.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_bodies_isolated() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let manager = create_test_manager();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new("body1", 0, b"data1".to_vec()),
|
||||
BodyChunk::new("body2", 0, b"data2".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let ctx = manager.connect();
|
||||
|
||||
ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap();
|
||||
|
||||
let chunks1 = ctx.get_chunks("body1").unwrap();
|
||||
let chunks2 = ctx.get_chunks("body2").unwrap();
|
||||
|
||||
@@ -341,16 +399,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_large_chunk() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
// 1MB chunk
|
||||
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
|
||||
let body_id = "rs_large.request";
|
||||
insert(&manager, &[BodyChunk::new(body_id, 0, large_data.clone())]);
|
||||
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap();
|
||||
|
||||
let ctx = manager.connect();
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].data, large_data);
|
||||
|
||||
@@ -3,18 +3,25 @@ use crate::models::{AnyModel, UpsertModelInfo};
|
||||
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
|
||||
use rusqlite::params;
|
||||
use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::DbContext;
|
||||
|
||||
/// A read handle. Comes from the reader pool and can only query.
|
||||
///
|
||||
/// Anything that changes a row lives on [`WriteDb`], which is only ever handed
|
||||
/// out inside a transaction on the single writer connection. That split is
|
||||
/// what keeps the pool from filling with writers waiting on each other: there
|
||||
/// is one writer, so there is never a second one to wait for.
|
||||
pub struct ClientDb<'a> {
|
||||
pub(crate) ctx: DbContext<'a>,
|
||||
pub(crate) events_tx: mpsc::Sender<ModelPayload>,
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
Self { ctx, events_tx }
|
||||
pub fn new(ctx: DbContext<'a>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Access the underlying connection for custom queries.
|
||||
@@ -22,8 +29,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.ctx.conn()
|
||||
}
|
||||
|
||||
// --- Read delegates (thin wrappers over DbContext) ---
|
||||
|
||||
pub(crate) fn find_one<M>(
|
||||
&self,
|
||||
col: impl IntoColumnRef + IntoIden + Clone,
|
||||
@@ -64,6 +69,39 @@ impl<'a> ClientDb<'a> {
|
||||
{
|
||||
Ok(self.ctx.find_many(col, value, limit)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A write handle: a [`ClientDb`] on the writer connection, inside a
|
||||
/// transaction, that can also change rows. Derefs to [`ClientDb`] so every
|
||||
/// query is available while writing, and reads inside the transaction see
|
||||
/// its own uncommitted writes.
|
||||
///
|
||||
/// Model events are held back until the transaction commits; a rollback
|
||||
/// discards them along with the rows.
|
||||
pub struct WriteDb<'a> {
|
||||
db: ClientDb<'a>,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
pending_events: RefCell<Vec<ModelPayload>>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for WriteDb<'a> {
|
||||
type Target = ClientDb<'a>;
|
||||
|
||||
fn deref(&self) -> &ClientDb<'a> {
|
||||
&self.db
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
Self { db: ClientDb::new(ctx), events_tx, pending_events: RefCell::new(Vec::new()) }
|
||||
}
|
||||
|
||||
/// The events for everything written so far, to send once the
|
||||
/// transaction has committed.
|
||||
pub(crate) fn into_events(self) -> Vec<ModelPayload> {
|
||||
self.pending_events.into_inner()
|
||||
}
|
||||
|
||||
/// Bulk-delete all rows matching a column value WITHOUT recording model
|
||||
/// changes or emitting events. Only use for cascades whose deletion is
|
||||
@@ -80,8 +118,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(self.ctx.delete_many::<M>(col, value)?)
|
||||
}
|
||||
|
||||
// --- Write operations (with event recording) ---
|
||||
|
||||
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
|
||||
where
|
||||
M: Into<AnyModel> + UpsertModelInfo + Clone,
|
||||
@@ -95,7 +131,7 @@ impl<'a> ClientDb<'a> {
|
||||
};
|
||||
|
||||
self.record_model_change(&payload)?;
|
||||
let _ = self.events_tx.send(payload);
|
||||
self.pending_events.borrow_mut().push(payload);
|
||||
|
||||
Ok(m)
|
||||
}
|
||||
@@ -113,7 +149,7 @@ impl<'a> ClientDb<'a> {
|
||||
};
|
||||
|
||||
self.record_model_change(&payload)?;
|
||||
let _ = self.events_tx.send(payload);
|
||||
self.pending_events.borrow_mut().push(payload);
|
||||
|
||||
Ok(m.clone())
|
||||
}
|
||||
|
||||
@@ -64,6 +64,17 @@ mod open {
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
/// `(readers, writer)` over one file: a pool of `max_size` readers and a
|
||||
/// pool of exactly one writer.
|
||||
pub fn file_pools(
|
||||
path: impl Into<PathBuf>,
|
||||
max_size: u32,
|
||||
min_idle: u32,
|
||||
) -> Result<(SqlitePool, SqlitePool)> {
|
||||
let path: PathBuf = path.into();
|
||||
Ok((file_pool(&path, max_size, min_idle)?, file_pool(&path, 1, 1)?))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
|
||||
// In-memory DB doesn't support multiple connections
|
||||
@@ -90,6 +101,16 @@ mod open {
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
|
||||
/// One connection is all a browser VFS allows, so it reads and writes.
|
||||
pub fn file_pools(
|
||||
path: impl Into<PathBuf>,
|
||||
max_size: u32,
|
||||
min_idle: u32,
|
||||
) -> Result<(SqlitePool, SqlitePool)> {
|
||||
let pool = file_pool(path, max_size, min_idle)?;
|
||||
Ok((pool.clone(), pool))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
init_connection(&conn)?;
|
||||
@@ -108,27 +129,33 @@ pub fn init_standalone(
|
||||
let db_path = db_path.as_ref();
|
||||
let blob_path = blob_path.as_ref();
|
||||
|
||||
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
|
||||
// features — connections are held per-statement, so even heavy fan-out (e.g. many
|
||||
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
|
||||
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
|
||||
// Each database gets a reader pool and a one-connection writer pool; see
|
||||
// `QueryManager` for why. Reader pools are sized for concurrent in-flight
|
||||
// queries, not concurrent app features — connections are held per-statement,
|
||||
// so even heavy fan-out (e.g. many gRPC streams) only needs a handful at once.
|
||||
// Keep them modest: WAL connections hold ~3 file descriptors each, and macOS
|
||||
// GUI apps get a 256 fd soft limit.
|
||||
info!("Initializing app database {db_path:?}");
|
||||
let pool = open::file_pool(db_path, 20, 2)?;
|
||||
migrate_db(&pool)?;
|
||||
let (readers, writer) = open::file_pools(db_path, 20, 2)?;
|
||||
migrate_db(&writer)?;
|
||||
|
||||
info!("Initializing blobs database {blob_path:?}");
|
||||
let blob_pool = open::file_pool(blob_path, 10, 1)?;
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
let (blob_readers, blob_writer) = open::file_pools(blob_path, 10, 1)?;
|
||||
migrate_blob_db(&blob_writer)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let query_manager = QueryManager::new(pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool);
|
||||
let query_manager = QueryManager::new(readers, writer, tx);
|
||||
let blob_manager = BlobManager::new(blob_readers, blob_writer);
|
||||
bootstrap(&query_manager)?;
|
||||
|
||||
Ok((query_manager, blob_manager, rx))
|
||||
}
|
||||
|
||||
/// Initialize the database managers with in-memory SQLite databases.
|
||||
/// Useful for testing and CI environments.
|
||||
///
|
||||
/// An in-memory database is private to its connection, so the one connection
|
||||
/// is both the reader pool and the writer.
|
||||
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
|
||||
let pool = open::memory_pool()?;
|
||||
migrate_db(&pool)?;
|
||||
@@ -137,8 +164,18 @@ pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<Mod
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let query_manager = QueryManager::new(pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool);
|
||||
let query_manager = QueryManager::new(pool.clone(), pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool.clone(), blob_pool);
|
||||
bootstrap(&query_manager)?;
|
||||
|
||||
Ok((query_manager, blob_manager, rx))
|
||||
}
|
||||
|
||||
/// The rows every client assumes exist: settings and at least one workspace.
|
||||
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
|
||||
query_manager.with_tx(|tx| {
|
||||
tx.ensure_settings()?;
|
||||
tx.ensure_default_workspace()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
//! caller is a desktop window or an HTTP request.
|
||||
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::WriteDb;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::models::AnyModel;
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
pub fn upsert_model(
|
||||
db: &ClientDb,
|
||||
db: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
@@ -41,7 +41,7 @@ pub fn upsert_model(
|
||||
|
||||
/// Deletes cascade, so callers run this inside a transaction.
|
||||
pub fn delete_model(
|
||||
tx: &ClientDb,
|
||||
tx: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
@@ -69,7 +69,7 @@ pub fn delete_model(
|
||||
/// The model is re-read from the database rather than taken from the caller, so
|
||||
/// a duplicate never comes from a stale frontend snapshot.
|
||||
pub fn duplicate_model(
|
||||
tx: &ClientDb,
|
||||
tx: &WriteDb,
|
||||
model_type: &str,
|
||||
model_id: &str,
|
||||
source: &UpdateSource,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::WriteDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
|
||||
use crate::util::{BatchUpsertResult, UpdateSource};
|
||||
use log::info;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn batch_upsert(
|
||||
&self,
|
||||
workspaces: Vec<Workspace>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{CookieJar, CookieJarIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -9,18 +9,22 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn list_cookie_jars(&self, workspace_id: &str) -> Result<Vec<CookieJar>> {
|
||||
let mut cookie_jars = self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)?;
|
||||
self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
if cookie_jars.is_empty() {
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// A workspace with no cookie jar gets a default one.
|
||||
pub fn ensure_default_cookie_jar(&self, workspace_id: &str) -> Result<()> {
|
||||
if self.list_cookie_jars(workspace_id)?.is_empty() {
|
||||
let jar = CookieJar {
|
||||
name: "Default".to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
cookie_jars.push(self.upsert_cookie_jar(&jar, &UpdateSource::Background)?);
|
||||
self.upsert_cookie_jar(&jar, &UpdateSource::Background)?;
|
||||
}
|
||||
|
||||
Ok(cookie_jars)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_cookie_jar(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::conflict_free_name;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
|
||||
use crate::error::Result;
|
||||
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
|
||||
@@ -20,7 +20,7 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
|
||||
let environments = self.list_environments_ensure_base(workspace_id)?;
|
||||
let environments = self.list_environments(workspace_id)?;
|
||||
let base_environments = environments
|
||||
.into_iter()
|
||||
.filter(|e| e.parent_model == "workspace")
|
||||
@@ -30,38 +30,96 @@ impl<'a> ClientDb<'a> {
|
||||
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(base_environments.first().cloned().ok_or(
|
||||
// Should never happen because one should be created above if it does not exist
|
||||
MissingBaseEnvironment(workspace_id.to_string()),
|
||||
)?)
|
||||
Ok(base_environments
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or(MissingBaseEnvironment(workspace_id.to_string()))?)
|
||||
}
|
||||
|
||||
/// Lists environments and will create a base environment if one doesn't exist
|
||||
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||
let mut environments = self.list_environments_dangerous(workspace_id)?;
|
||||
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
|
||||
}
|
||||
|
||||
let base_environment = environments.iter().find(|e| e.parent_model == "workspace");
|
||||
/// Find other environments with the same parent folder
|
||||
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
|
||||
if environment.parent_model != "folder" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if let None = base_environment {
|
||||
let e = self.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: "Global Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
self.list_environments(&environment.workspace_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.id != environment.id
|
||||
&& e.parent_model == "folder"
|
||||
&& e.parent_id == environment.parent_id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn resolve_environments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
folder_id: Option<&str>,
|
||||
active_environment_id: Option<&str>,
|
||||
) -> Result<Vec<Environment>> {
|
||||
let mut environments = Vec::new();
|
||||
|
||||
if let Some(folder_id) = folder_id {
|
||||
let folder = self.get_folder(folder_id)?;
|
||||
|
||||
// Add current folder's environment
|
||||
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
|
||||
environments.push(e);
|
||||
};
|
||||
|
||||
// Recurse up
|
||||
let ancestors = self.resolve_environments(
|
||||
workspace_id,
|
||||
folder.folder_id.as_deref(),
|
||||
active_environment_id,
|
||||
)?;
|
||||
info!("Created base environment {} for {workspace_id}", e.id);
|
||||
environments.push(e);
|
||||
environments.extend(ancestors);
|
||||
} else {
|
||||
// Add active and base environments
|
||||
if let Some(id) = active_environment_id {
|
||||
if let Ok(e) = self.get_environment(&id) {
|
||||
// Add active sub environment
|
||||
environments.push(e);
|
||||
};
|
||||
};
|
||||
|
||||
// Add the base environment. A workspace that has never been
|
||||
// opened has none yet; it simply contributes no variables.
|
||||
match self.get_base_environment(workspace_id) {
|
||||
Ok(e) => environments.push(e),
|
||||
Err(MissingBaseEnvironment(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(environments)
|
||||
}
|
||||
}
|
||||
|
||||
/// List environments for a workspace. Prefer list_environments_ensure_base()
|
||||
fn list_environments_dangerous(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// The workspace's base environment, created if it does not exist.
|
||||
pub fn ensure_base_environment(&self, workspace_id: &str) -> Result<Environment> {
|
||||
match self.get_base_environment(workspace_id) {
|
||||
Err(MissingBaseEnvironment(_)) => {}
|
||||
other => return other,
|
||||
}
|
||||
let e = self.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: "Global Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
info!("Created base environment {} for {workspace_id}", e.id);
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
pub fn delete_environment(
|
||||
@@ -72,7 +130,7 @@ impl<'a> ClientDb<'a> {
|
||||
let deleted_environment = self.delete(environment, source)?;
|
||||
|
||||
// Recreate the base environment if we happened to delete it
|
||||
self.list_environments_ensure_base(&environment.workspace_id)?;
|
||||
self.ensure_base_environment(&environment.workspace_id)?;
|
||||
|
||||
Ok(deleted_environment)
|
||||
}
|
||||
@@ -90,7 +148,7 @@ impl<'a> ClientDb<'a> {
|
||||
let mut environment = environment.clone();
|
||||
environment.id = "".to_string();
|
||||
let sibling_names = self
|
||||
.list_environments_dangerous(&environment.workspace_id)?
|
||||
.list_environments(&environment.workspace_id)?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -98,23 +156,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.upsert_environment(&environment, source)
|
||||
}
|
||||
|
||||
/// Find other environments with the same parent folder
|
||||
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
|
||||
if environment.parent_model != "folder" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.list_environments_dangerous(&environment.workspace_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.id != environment.id
|
||||
&& e.parent_model == "folder"
|
||||
&& e.parent_id == environment.parent_id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn upsert_environment(
|
||||
&self,
|
||||
environment: &Environment,
|
||||
@@ -154,43 +195,4 @@ impl<'a> ClientDb<'a> {
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_environments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
folder_id: Option<&str>,
|
||||
active_environment_id: Option<&str>,
|
||||
) -> Result<Vec<Environment>> {
|
||||
let mut environments = Vec::new();
|
||||
|
||||
if let Some(folder_id) = folder_id {
|
||||
let folder = self.get_folder(folder_id)?;
|
||||
|
||||
// Add current folder's environment
|
||||
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
|
||||
environments.push(e);
|
||||
};
|
||||
|
||||
// Recurse up
|
||||
let ancestors = self.resolve_environments(
|
||||
workspace_id,
|
||||
folder.folder_id.as_deref(),
|
||||
active_environment_id,
|
||||
)?;
|
||||
environments.extend(ancestors);
|
||||
} else {
|
||||
// Add active and base environments
|
||||
if let Some(id) = active_environment_id {
|
||||
if let Ok(e) = self.get_environment(&id) {
|
||||
// Add active sub environment
|
||||
environments.push(e);
|
||||
};
|
||||
};
|
||||
|
||||
// Add the base environment
|
||||
environments.push(self.get_base_environment(workspace_id)?);
|
||||
}
|
||||
|
||||
Ok(environments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::connection_or_tx::ConnectionOrTx;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
@@ -20,99 +20,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_many(FolderIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
match self.conn() {
|
||||
ConnectionOrTx::Connection(_) => {}
|
||||
ConnectionOrTx::Transaction(_) => {}
|
||||
}
|
||||
|
||||
let fid = &folder.id;
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.delete_http_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.delete_grpc_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.delete_websocket_request(&m, source)?;
|
||||
}
|
||||
|
||||
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.delete_environment(&e, source)?;
|
||||
}
|
||||
|
||||
// Recurse down into child folders
|
||||
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
self.delete_folder(&folder, source)?;
|
||||
}
|
||||
|
||||
self.delete(folder, source)
|
||||
}
|
||||
|
||||
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
|
||||
let folder = self.get_folder(id)?;
|
||||
self.delete_folder(&folder, source)
|
||||
}
|
||||
|
||||
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
self.upsert(folder, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
let fid = &src_folder.id;
|
||||
|
||||
let mut folder = Folder {
|
||||
id: "".into(),
|
||||
sort_priority: src_folder.sort_priority + 0.001,
|
||||
..src_folder.clone()
|
||||
};
|
||||
let sibling_names = self
|
||||
.list_folders(&folder.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|f| f.folder_id == folder.folder_id)
|
||||
.map(|f| f.name)
|
||||
.collect::<Vec<_>>();
|
||||
folder.name = conflict_free_name(&folder.name, &sibling_names);
|
||||
let new_folder = self.upsert_folder(&folder, source)?;
|
||||
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_http_request(
|
||||
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_websocket_request(
|
||||
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_grpc_request(
|
||||
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.upsert_environment(
|
||||
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
// Recurse down
|
||||
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
|
||||
}
|
||||
|
||||
Ok(new_folder)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_folder(
|
||||
&self,
|
||||
folder: &Folder,
|
||||
@@ -219,3 +126,98 @@ impl<'a> ClientDb<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
match self.conn() {
|
||||
ConnectionOrTx::Connection(_) => {}
|
||||
ConnectionOrTx::Transaction(_) => {}
|
||||
}
|
||||
|
||||
let fid = &folder.id;
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.delete_http_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.delete_grpc_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.delete_websocket_request(&m, source)?;
|
||||
}
|
||||
|
||||
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.delete_environment(&e, source)?;
|
||||
}
|
||||
|
||||
// Recurse down into child folders
|
||||
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
self.delete_folder(&folder, source)?;
|
||||
}
|
||||
|
||||
self.delete(folder, source)
|
||||
}
|
||||
|
||||
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
|
||||
let folder = self.get_folder(id)?;
|
||||
self.delete_folder(&folder, source)
|
||||
}
|
||||
|
||||
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
self.upsert(folder, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
let fid = &src_folder.id;
|
||||
|
||||
let mut folder = Folder {
|
||||
id: "".into(),
|
||||
sort_priority: src_folder.sort_priority + 0.001,
|
||||
..src_folder.clone()
|
||||
};
|
||||
let sibling_names = self
|
||||
.list_folders(&folder.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|f| f.folder_id == folder.folder_id)
|
||||
.map(|f| f.name)
|
||||
.collect::<Vec<_>>();
|
||||
folder.name = conflict_free_name(&folder.name, &sibling_names);
|
||||
let new_folder = self.upsert_folder(&folder, source)?;
|
||||
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_http_request(
|
||||
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_websocket_request(
|
||||
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_grpc_request(
|
||||
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.upsert_environment(
|
||||
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
// Recurse down
|
||||
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
|
||||
}
|
||||
|
||||
Ok(new_folder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {
|
||||
self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_graphql_introspection(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
@@ -13,6 +13,20 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(GrpcConnectionIden::Id, id)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_all_grpc_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
@@ -53,18 +67,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.delete_grpc_connection(&grpc_connection, source)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn cancel_pending_grpc_connections(&self) -> Result<()> {
|
||||
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
|
||||
let (sql, params) = Query::update()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcEvent, GrpcEventIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_grpc_events(&self, connection_id: &str) -> Result<Vec<GrpcEvent>> {
|
||||
self.find_many(GrpcEventIden::ConnectionId, connection_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_grpc_event(
|
||||
&self,
|
||||
grpc_event: &GrpcEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader,
|
||||
@@ -32,50 +32,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
pub fn delete_grpc_request(
|
||||
&self,
|
||||
m: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_grpc_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let request = self.get_grpc_request(id)?;
|
||||
self.delete_grpc_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let mut request = grpc_request.clone();
|
||||
request.id = "".to_string();
|
||||
request.sort_priority = request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_grpc_requests(&request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
request.name = conflict_free_name(&request.name, &sibling_names);
|
||||
self.upsert(&request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.upsert(grpc_request, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
@@ -146,3 +102,49 @@ impl<'a> ClientDb<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_grpc_request(
|
||||
&self,
|
||||
m: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_grpc_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let request = self.get_grpc_request(id)?;
|
||||
self.delete_grpc_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let mut request = grpc_request.clone();
|
||||
request.id = "".to_string();
|
||||
request.sort_priority = request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_grpc_requests(&request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
request.name = conflict_free_name(&request.name, &sibling_names);
|
||||
self.upsert(&request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.upsert(grpc_request, source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
@@ -18,50 +18,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn delete_http_request(
|
||||
&self,
|
||||
m: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_http_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let http_request = self.get_http_request(id)?;
|
||||
self.delete_http_request(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let mut http_request = http_request.clone();
|
||||
http_request.id = "".to_string();
|
||||
http_request.sort_priority = http_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_http_requests(&http_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == http_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
|
||||
self.upsert(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.upsert(http_request, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
@@ -179,6 +135,52 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_http_request(
|
||||
&self,
|
||||
m: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_http_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let http_request = self.get_http_request(id)?;
|
||||
self.delete_http_request(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let mut http_request = http_request.clone();
|
||||
http_request.id = "".to_string();
|
||||
http_request.sort_priority = http_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_http_requests(&http_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == http_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
|
||||
self.upsert(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.upsert(http_request, source)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::init_in_memory;
|
||||
@@ -225,72 +227,83 @@ mod tests {
|
||||
#[test]
|
||||
fn http_version_resolves_through_the_inheritance_chain() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let source = &UpdateSource::Background;
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Test".to_string(),
|
||||
setting_http_version: HttpVersion::Http2,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let folder = db
|
||||
.upsert_folder(
|
||||
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert folder");
|
||||
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
folder_id: Some(folder.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
let (folder, request) = query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Test".to_string(),
|
||||
setting_http_version: HttpVersion::Http2,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
let folder = db.upsert_folder(
|
||||
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
folder_id: Some(folder.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, crate::error::Error>((folder, request))
|
||||
})
|
||||
.expect("Failed to seed");
|
||||
|
||||
// No overrides, so the workspace base value applies
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
let resolved = query_manager
|
||||
.connect()
|
||||
.resolve_settings_for_http_request(&request)
|
||||
.expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Http2);
|
||||
assert_eq!(resolved.http_version.source_model, "workspace");
|
||||
|
||||
// A folder override beats the workspace base
|
||||
db.upsert_folder(
|
||||
&Folder {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Http1,
|
||||
},
|
||||
..folder
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to update folder");
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_folder(
|
||||
&Folder {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Http1,
|
||||
},
|
||||
..folder
|
||||
},
|
||||
source,
|
||||
)
|
||||
})
|
||||
.expect("Failed to update folder");
|
||||
let resolved = query_manager
|
||||
.connect()
|
||||
.resolve_settings_for_http_request(&request)
|
||||
.expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Http1);
|
||||
assert_eq!(resolved.http_version.source_model, "folder");
|
||||
|
||||
// A request override beats them both
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Auto,
|
||||
let request = query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Auto,
|
||||
},
|
||||
..request
|
||||
},
|
||||
..request
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
source,
|
||||
)
|
||||
})
|
||||
.expect("Failed to update request");
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
let resolved = query_manager
|
||||
.connect()
|
||||
.resolve_settings_for_http_request(&request)
|
||||
.expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Auto);
|
||||
assert_eq!(resolved.http_version.source_model, "http_request");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{HttpResponseEvent, HttpResponseEventIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -7,7 +7,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {
|
||||
self.find_many(HttpResponseEventIden::ResponseId, response_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_http_response_event(
|
||||
&self,
|
||||
http_response_event: &HttpResponseEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
@@ -31,20 +31,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)
|
||||
}
|
||||
|
||||
/// Returns the number of responses deleted.
|
||||
pub fn delete_all_http_responses_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<usize> {
|
||||
let responses = self.list_http_responses_for_request(request_id, None)?;
|
||||
let count = responses.len();
|
||||
for m in responses {
|
||||
self.delete(&m, source)?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Delete blob-stored response bodies whose owning HTTP response row no
|
||||
/// longer exists. Blob ids are keyed by the response that owns them —
|
||||
/// "{response_id}" for a response body, "{response_id}.request" for the
|
||||
@@ -55,19 +41,24 @@ impl<'a> ClientDb<'a> {
|
||||
///
|
||||
/// Returns the number of orphaned bodies deleted.
|
||||
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
let orphaned = blobs
|
||||
.connect()
|
||||
.list_body_ids()?
|
||||
.into_iter()
|
||||
.filter(|body_id| {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_none()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let blob_ctx = blobs.connect();
|
||||
for body_id in blob_ctx.list_body_ids()? {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
blobs.with_tx(|b| {
|
||||
for body_id in &orphaned {
|
||||
b.delete_chunks(body_id)?;
|
||||
}
|
||||
blob_ctx.delete_chunks(&body_id)?;
|
||||
deleted += 1;
|
||||
}
|
||||
Ok::<_, crate::error::Error>(())
|
||||
})?;
|
||||
|
||||
Ok(deleted)
|
||||
Ok(orphaned.len())
|
||||
}
|
||||
|
||||
/// Delete response body data (blob chunks and body files) whose owning HTTP
|
||||
@@ -107,6 +98,22 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// Returns the number of responses deleted.
|
||||
pub fn delete_all_http_responses_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<usize> {
|
||||
let responses = self.list_http_responses_for_request(request_id, None)?;
|
||||
let count = responses.len();
|
||||
for m in responses {
|
||||
self.delete(&m, source)?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Returns the number of responses deleted.
|
||||
pub fn delete_all_http_responses_for_workspace(
|
||||
@@ -137,9 +144,8 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
// Delete request body blobs (pattern: {response_id}.request)
|
||||
let blob_ctx = blob_manager.connect();
|
||||
let body_id = format!("{}.request", http_response.id);
|
||||
if let Err(e) = blob_ctx.delete_chunks(&body_id) {
|
||||
if let Err(e) = blob_manager.with_tx(|b| b.delete_chunks(&body_id)) {
|
||||
error!("Failed to delete request body blobs: {}", e);
|
||||
}
|
||||
|
||||
@@ -186,36 +192,50 @@ impl<'a> ClientDb<'a> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::blob_manager::{BlobManager, BodyChunk};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Error;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
/// A workspace, a request, and one response that still exists.
|
||||
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
|
||||
fn seed_live_response(
|
||||
query_manager: &QueryManager,
|
||||
blob_manager: &BlobManager,
|
||||
) -> HttpResponse {
|
||||
let source = &UpdateSource::Background;
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "GC Test".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
blob_manager,
|
||||
)
|
||||
.expect("Failed to upsert response")
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace { name: "GC Test".to_string(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
blob_manager,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed response")
|
||||
}
|
||||
|
||||
fn insert_bodies(blob_manager: &BlobManager, body_ids: &[&str]) {
|
||||
blob_manager
|
||||
.with_tx(|b| {
|
||||
for id in body_ids {
|
||||
b.insert_chunk(&BodyChunk::new(*id, 0, b"data".to_vec()))?;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.expect("Failed to insert chunks");
|
||||
}
|
||||
|
||||
/// What a browser host runs: no filesystem, so bodies exist only as blob
|
||||
@@ -223,23 +243,21 @@ mod tests {
|
||||
#[test]
|
||||
fn deletes_orphaned_response_body_blobs() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let live = seed_live_response(&db, &blob_manager);
|
||||
let live = seed_live_response(&query_manager, &blob_manager);
|
||||
let live_request_body_id = format!("{}.request", live.id);
|
||||
{
|
||||
// Scope the connection: the in-memory pool only has one, and the GC
|
||||
// needs to take it
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
|
||||
blob_ctx
|
||||
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
|
||||
.unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
insert_bodies(
|
||||
&blob_manager,
|
||||
&[
|
||||
&live.id,
|
||||
&live_request_body_id,
|
||||
"rs_gone",
|
||||
"rs_gone.request",
|
||||
],
|
||||
);
|
||||
|
||||
let deleted = db
|
||||
let deleted = query_manager
|
||||
.connect()
|
||||
.delete_orphaned_response_body_blobs(&blob_manager)
|
||||
.expect("Failed to GC response body blobs");
|
||||
assert_eq!(deleted, 2);
|
||||
@@ -254,24 +272,18 @@ mod tests {
|
||||
#[test]
|
||||
fn deletes_orphaned_response_bodies() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let live = seed_live_response(&db, &blob_manager);
|
||||
let live = seed_live_response(&query_manager, &blob_manager);
|
||||
let live_body_id = format!("{}.request", live.id);
|
||||
{
|
||||
// Scope the connection: the in-memory pool only has one, and the GC
|
||||
// needs to take it
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new(&live_body_id, 0, b"live".to_vec())).unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
insert_bodies(&blob_manager, &[&live_body_id, "rs_gone.request"]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("yaak-blob-gc-test-{}", live.id));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(&live.id), b"live").unwrap();
|
||||
std::fs::write(dir.join("rs_gone"), b"dead").unwrap();
|
||||
|
||||
let deleted = db
|
||||
let deleted = query_manager
|
||||
.connect()
|
||||
.delete_orphaned_response_bodies(&blob_manager, &dir)
|
||||
.expect("Failed to GC response bodies");
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{ImportSourceResource, ImportSourceResourceIden};
|
||||
use sea_query::ExprTrait;
|
||||
@@ -20,7 +20,9 @@ impl<'a> ClientDb<'a> {
|
||||
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
||||
Ok(items.filter_map(|v| v.ok()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_import_source_resource(
|
||||
&self,
|
||||
resource: &ImportSourceResource,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{ImportSource, ImportSourceIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -25,7 +25,9 @@ impl<'a> ClientDb<'a> {
|
||||
let sources = self.list_import_sources(workspace_id)?;
|
||||
Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_import_source(
|
||||
&self,
|
||||
import_source: &ImportSource,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -89,7 +89,9 @@ impl<'a> ClientDb<'a> {
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn set_key_value_dte(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::util::ModelPayload;
|
||||
use rusqlite::params;
|
||||
@@ -69,7 +69,9 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
Ok(items.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn prune_model_changes_older_than_days(&self, days: i64) -> Result<usize> {
|
||||
let offset = format!("-{days} days");
|
||||
Ok(self.conn().resolve().execute(
|
||||
@@ -101,23 +103,36 @@ mod tests {
|
||||
use crate::util::{ModelChangeEvent, UpdateSource};
|
||||
use serde_json::json;
|
||||
|
||||
/// Startup bootstraps rows of its own; these tests count only their own.
|
||||
fn clear_changes(query_manager: &crate::query_manager::QueryManager) {
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.conn().resolve().execute("DELETE FROM model_changes", [])?;
|
||||
Ok::<_, crate::error::Error>(())
|
||||
})
|
||||
.expect("Failed to clear model changes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_model_changes_for_upsert_and_delete() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
clear_changes(&query_manager);
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Changes Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
let workspace = query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Changes Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
let created_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(created_changes.len(), 1);
|
||||
assert_eq!(created_changes[0].payload.model.id(), workspace.id);
|
||||
@@ -128,9 +143,14 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(created_changes[0].payload.update_source, UpdateSource::Sync));
|
||||
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
drop(db);
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
})
|
||||
.expect("Failed to delete workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
let all_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(all_changes.len(), 2);
|
||||
assert!(matches!(all_changes[1].payload.change, ModelChangeEvent::Delete));
|
||||
@@ -146,19 +166,23 @@ mod tests {
|
||||
#[test]
|
||||
fn prunes_old_model_changes() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(changes.len(), 1);
|
||||
|
||||
@@ -170,31 +194,41 @@ mod tests {
|
||||
)
|
||||
.expect("Failed to age model change row");
|
||||
|
||||
let pruned =
|
||||
db.prune_model_changes_older_than_days(30).expect("Failed to prune model changes");
|
||||
drop(db);
|
||||
let pruned = query_manager
|
||||
.with_tx(|db| db.prune_model_changes_older_than_days(30))
|
||||
.expect("Failed to prune model changes");
|
||||
assert_eq!(pruned, 1);
|
||||
assert!(db.list_model_changes_after(0, 10).expect("Failed to list changes").is_empty());
|
||||
assert!(
|
||||
query_manager
|
||||
.connect()
|
||||
.list_model_changes_after(0, 10)
|
||||
.expect("Failed to list changes")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Cursor Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
})
|
||||
.expect("Failed to seed changes");
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Cursor Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
.expect("Failed to delete workspace");
|
||||
|
||||
let all = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
@@ -213,19 +247,23 @@ mod tests {
|
||||
#[test]
|
||||
fn prunes_old_model_changes_by_hours() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Hour Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Hour Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(changes.len(), 1);
|
||||
|
||||
@@ -237,14 +275,17 @@ mod tests {
|
||||
)
|
||||
.expect("Failed to age model change row");
|
||||
|
||||
let pruned =
|
||||
db.prune_model_changes_older_than_hours(1).expect("Failed to prune model changes");
|
||||
drop(db);
|
||||
let pruned = query_manager
|
||||
.with_tx(|db| db.prune_model_changes_older_than_hours(1))
|
||||
.expect("Failed to prune model changes");
|
||||
assert_eq!(pruned, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_model_changes_deserializes_http_response_event_payload() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
let db = query_manager.connect();
|
||||
|
||||
let payload = json!({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{PluginKeyValue, PluginKeyValueIden};
|
||||
use sea_query::ExprTrait;
|
||||
@@ -22,7 +22,9 @@ impl<'a> ClientDb<'a> {
|
||||
.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn set_plugin_key_value(
|
||||
&self,
|
||||
plugin_name: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{Plugin, PluginIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -15,7 +15,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_plugins(&self) -> Result<Vec<Plugin>> {
|
||||
self.find_all()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
|
||||
self.delete(plugin, source)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{EditorKeymap, Settings, SettingsIden};
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
/// The settings row, or the defaults if it has not been written yet.
|
||||
/// [`WriteDb::ensure_settings`] persists it at startup.
|
||||
pub fn get_settings(&self) -> Settings {
|
||||
let id = "default".to_string();
|
||||
self.find_optional::<Settings>(SettingsIden::Id, "default").unwrap_or_else(default_settings)
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, &id) {
|
||||
return s;
|
||||
};
|
||||
|
||||
let settings = Settings {
|
||||
model: "settings".to_string(),
|
||||
id,
|
||||
created_at: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
|
||||
appearance: "system".to_string(),
|
||||
client_certificates: Vec::new(),
|
||||
editor_font_size: 12,
|
||||
editor_font: None,
|
||||
editor_keymap: EditorKeymap::Default,
|
||||
editor_soft_wrap: true,
|
||||
interface_font_size: 14,
|
||||
interface_scale: 1.0,
|
||||
interface_font: None,
|
||||
hide_window_controls: false,
|
||||
use_native_titlebar: false,
|
||||
open_workspace_new_window: None,
|
||||
proxy: None,
|
||||
theme_dark: "yaak-dark".to_string(),
|
||||
theme_light: "yaak-light".to_string(),
|
||||
update_channel: "stable".to_string(),
|
||||
autoupdate: true,
|
||||
colored_methods: false,
|
||||
hide_license_badge: false,
|
||||
prompt_feedback: true,
|
||||
auto_download_updates: true,
|
||||
check_notifications: true,
|
||||
hotkeys: HashMap::new(),
|
||||
};
|
||||
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// Create the settings row if it does not exist.
|
||||
pub fn ensure_settings(&self) -> Result<Settings> {
|
||||
if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, "default") {
|
||||
return Ok(s);
|
||||
}
|
||||
self.upsert(&default_settings(), &UpdateSource::Background)
|
||||
}
|
||||
|
||||
pub fn upsert_settings(&self, settings: &Settings, source: &UpdateSource) -> Result<Settings> {
|
||||
self.upsert(settings, source)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_settings() -> Settings {
|
||||
Settings {
|
||||
model: "settings".to_string(),
|
||||
id: "default".to_string(),
|
||||
created_at: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
|
||||
appearance: "system".to_string(),
|
||||
client_certificates: Vec::new(),
|
||||
editor_font_size: 12,
|
||||
editor_font: None,
|
||||
editor_keymap: EditorKeymap::Default,
|
||||
editor_soft_wrap: true,
|
||||
interface_font_size: 14,
|
||||
interface_scale: 1.0,
|
||||
interface_font: None,
|
||||
hide_window_controls: false,
|
||||
use_native_titlebar: false,
|
||||
open_workspace_new_window: None,
|
||||
proxy: None,
|
||||
theme_dark: "yaak-dark".to_string(),
|
||||
theme_light: "yaak-light".to_string(),
|
||||
update_channel: "stable".to_string(),
|
||||
autoupdate: true,
|
||||
colored_methods: false,
|
||||
hide_license_badge: false,
|
||||
prompt_feedback: true,
|
||||
auto_download_updates: true,
|
||||
check_notifications: true,
|
||||
hotkeys: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -12,10 +12,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(SyncStateIden::Id, id)
|
||||
}
|
||||
|
||||
pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
|
||||
self.upsert(sync_state, &UpdateSource::Sync)
|
||||
}
|
||||
|
||||
pub fn list_sync_states_for_workspace(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -34,6 +30,12 @@ impl<'a> ClientDb<'a> {
|
||||
let items = stmt.query_map(&*params.as_params(), SyncState::from_row)?;
|
||||
Ok(items.map(|v| v.unwrap()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
|
||||
self.upsert(sync_state, &UpdateSource::Sync)
|
||||
}
|
||||
|
||||
pub fn delete_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
|
||||
self.delete(sync_state, &UpdateSource::Sync)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConnectionState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
@@ -13,6 +13,22 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(WebsocketConnectionIden::Id, id)
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::RequestId, request_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_all_websocket_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
@@ -37,20 +53,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::RequestId, request_id, None)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_connection(
|
||||
&self,
|
||||
websocket_connection: &WebsocketConnection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{WebsocketEvent, WebsocketEventIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_websocket_events(&self, connection_id: &str) -> Result<Vec<WebsocketEvent>> {
|
||||
self.find_many(WebsocketEventIden::ConnectionId, connection_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_websocket_event(
|
||||
&self,
|
||||
websocket_event: &WebsocketEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, Folder, FolderIden, HttpRequestHeader, ResolvedHttpRequestSettings, ResolvedSetting,
|
||||
@@ -34,50 +34,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let request = self.get_websocket_request(id)?;
|
||||
self.delete_websocket_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let mut websocket_request = websocket_request.clone();
|
||||
websocket_request.id = "".to_string();
|
||||
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_websocket_requests(&websocket_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == websocket_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
|
||||
self.upsert(&websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.upsert(websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
@@ -168,3 +124,49 @@ impl<'a> ClientDb<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let request = self.get_websocket_request(id)?;
|
||||
self.delete_websocket_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let mut websocket_request = websocket_request.clone();
|
||||
websocket_request.id = "".to_string();
|
||||
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_websocket_requests(&websocket_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == websocket_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
|
||||
self.upsert(&websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.upsert(websocket_request, source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -10,18 +10,12 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn list_workspace_metas(&self, workspace_id: &str) -> Result<Vec<WorkspaceMeta>> {
|
||||
let mut workspace_metas =
|
||||
self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)?;
|
||||
|
||||
if workspace_metas.is_empty() {
|
||||
let wm = WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };
|
||||
workspace_metas.push(self.upsert_workspace_meta(&wm, &UpdateSource::Background)?)
|
||||
}
|
||||
|
||||
Ok(workspace_metas)
|
||||
self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn ensure_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
|
||||
let workspace_meta = self.get_workspace_meta(workspace_id);
|
||||
if let Some(workspace_meta) = workspace_meta {
|
||||
return Ok(workspace_meta);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::merge_headers;
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
|
||||
@@ -23,119 +23,7 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {
|
||||
let mut workspaces = self.find_all()?;
|
||||
|
||||
if workspaces.is_empty() {
|
||||
workspaces.push(self.upsert_workspace(
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?)
|
||||
}
|
||||
|
||||
Ok(workspaces)
|
||||
}
|
||||
|
||||
/// Delete a workspace and everything in it.
|
||||
///
|
||||
/// Children are bulk-deleted with one statement per table and are NOT
|
||||
/// individually recorded in model_changes or emitted as events — the single
|
||||
/// workspace delete event implies the subtree (see [`ModelChangeEvent::Delete`]).
|
||||
/// This keeps huge workspaces (thousands of requests) fast and avoids
|
||||
/// flooding event consumers.
|
||||
pub fn delete_workspace(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let wid = workspace.id.as_str();
|
||||
|
||||
// Collect response cleanup targets before their rows disappear. The actual
|
||||
// cleanup runs at the end: response bodies live on disk and in the blob DB,
|
||||
// which don't participate in this transaction, so removing them must wait
|
||||
// until every statement that could fail (and roll back the rows) is done.
|
||||
let responses = self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, wid, None)?;
|
||||
|
||||
// Sync and the CLI call this on a plain connection where each statement
|
||||
// would otherwise commit on its own, leaving a partially-deleted workspace
|
||||
// if one fails. A savepoint makes the cascade atomic there, and nests
|
||||
// harmlessly inside the interactive path's transaction.
|
||||
let conn = self.conn().resolve();
|
||||
conn.execute_batch("SAVEPOINT delete_workspace")?;
|
||||
|
||||
let result: Result<Workspace> = (|| {
|
||||
self.delete_many_untracked::<HttpResponseEvent>(
|
||||
HttpResponseEventIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<HttpResponse>(HttpResponseIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<HttpRequest>(HttpRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcEvent>(GrpcEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcConnection>(GrpcConnectionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcRequest>(GrpcRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketEvent>(WebsocketEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketConnection>(
|
||||
WebsocketConnectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<WebsocketRequest>(WebsocketRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GraphQlIntrospection>(
|
||||
GraphQlIntrospectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
|
||||
for import_source in self.list_import_sources(wid)? {
|
||||
self.delete_import_source_resources(&import_source.id)?;
|
||||
}
|
||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
self.delete(workspace, source)
|
||||
})();
|
||||
|
||||
let deleted = match result {
|
||||
Ok(deleted) => {
|
||||
conn.execute_batch("RELEASE delete_workspace")?;
|
||||
deleted
|
||||
}
|
||||
Err(e) => {
|
||||
let _ =
|
||||
conn.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Best-effort cleanup of response bodies (disk files and blob chunks).
|
||||
// Failures only orphan unreferenced data, and are logged.
|
||||
let blob_ctx = blobs.connect();
|
||||
for m in responses {
|
||||
if let Some(p) = m.body_path {
|
||||
if let Err(e) = std::fs::remove_file(&p) {
|
||||
warn!("Failed to delete response body file {p:?}: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = blob_ctx.delete_chunks_like(&format!("{}.%", m.id)) {
|
||||
warn!("Failed to delete blobs for response {}: {e}", m.id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub fn delete_workspace_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let workspace = self.get_workspace(id)?;
|
||||
self.delete_workspace(&workspace, source, blobs)
|
||||
}
|
||||
|
||||
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
||||
self.upsert(w, source)
|
||||
self.find_all()
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_workspace(
|
||||
@@ -190,6 +78,100 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// There is always at least one workspace. Called at startup and after a
|
||||
/// workspace is deleted.
|
||||
pub fn ensure_default_workspace(&self) -> Result<()> {
|
||||
if self.find_all::<Workspace>()?.is_empty() {
|
||||
self.upsert_workspace(
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a workspace and everything in it.
|
||||
///
|
||||
/// Children are bulk-deleted with one statement per table and are NOT
|
||||
/// individually recorded in model_changes or emitted as events — the single
|
||||
/// workspace delete event implies the subtree (see [`ModelChangeEvent::Delete`]).
|
||||
/// This keeps huge workspaces (thousands of requests) fast and avoids
|
||||
/// flooding event consumers.
|
||||
pub fn delete_workspace(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let wid = workspace.id.as_str();
|
||||
|
||||
// Collect response cleanup targets before their rows disappear. The actual
|
||||
// cleanup runs at the end: response bodies live on disk and in the blob DB,
|
||||
// which don't participate in this transaction, so removing them must wait
|
||||
// until every statement that could fail (and roll back the rows) is done.
|
||||
let responses = self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, wid, None)?;
|
||||
|
||||
self.delete_many_untracked::<HttpResponseEvent>(HttpResponseEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<HttpResponse>(HttpResponseIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<HttpRequest>(HttpRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcEvent>(GrpcEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcConnection>(GrpcConnectionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcRequest>(GrpcRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketEvent>(WebsocketEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketConnection>(
|
||||
WebsocketConnectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<WebsocketRequest>(WebsocketRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GraphQlIntrospection>(
|
||||
GraphQlIntrospectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
|
||||
for import_source in self.list_import_sources(wid)? {
|
||||
self.delete_import_source_resources(&import_source.id)?;
|
||||
}
|
||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
let deleted = self.delete(workspace, source)?;
|
||||
self.ensure_default_workspace()?;
|
||||
|
||||
// Best-effort cleanup of response bodies (disk files and blob chunks).
|
||||
// Failures only orphan unreferenced data, and are logged.
|
||||
for m in responses {
|
||||
if let Some(p) = m.body_path {
|
||||
if let Err(e) = std::fs::remove_file(&p) {
|
||||
warn!("Failed to delete response body file {p:?}: {e}");
|
||||
}
|
||||
}
|
||||
let pattern = format!("{}.%", m.id);
|
||||
if let Err(e) = blobs.with_tx(|b| b.delete_chunks_like(&pattern)) {
|
||||
warn!("Failed to delete blobs for response {}: {e}", m.id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub fn delete_workspace_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let workspace = self.get_workspace(id)?;
|
||||
self.delete_workspace(&workspace, source, blobs)
|
||||
}
|
||||
|
||||
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
||||
self.upsert(w, source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global default headers that are always sent with requests unless overridden.
|
||||
/// These are prepended to the inheritance chain so workspace/folder/request headers
|
||||
/// can override or disable them.
|
||||
|
||||
@@ -1,65 +1,82 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::util::ModelPayload;
|
||||
use rusqlite::{Transaction, TransactionBehavior};
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
|
||||
|
||||
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
/// Reads come from a pool; writes go through one connection.
|
||||
///
|
||||
/// SQLite in WAL mode lets many readers run alongside a single writer, and
|
||||
/// never more than one writer. A second in-process writer can only wait, and
|
||||
/// while it waits in the busy handler it sleeps, retries, and keeps its pool
|
||||
/// slot. Enough of those and the pool is full of writers that are all asleep,
|
||||
/// and every read in the app queues behind them. Giving writes exactly one
|
||||
/// connection turns that into a plain queue: the next write starts the moment
|
||||
/// the previous one commits, and it never takes a slot a read could use.
|
||||
///
|
||||
/// The pools are internally synchronized — don't wrap them in a Mutex. A Mutex
|
||||
/// held across the blocking `get()` serializes every DB access behind the
|
||||
/// slowest waiter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueryManager {
|
||||
pool: SqlitePool,
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
}
|
||||
|
||||
impl QueryManager {
|
||||
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
QueryManager { pool, events_tx }
|
||||
/// `writer` must be a pool with a single connection; see [`crate::init_standalone`].
|
||||
pub fn new(
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
) -> Self {
|
||||
QueryManager { readers, writer, events_tx }
|
||||
}
|
||||
|
||||
/// A read handle from the reader pool.
|
||||
pub fn connect(&self) -> ClientDb<'_> {
|
||||
let conn = self.pool.get().expect("Failed to get a new DB connection from the pool");
|
||||
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
|
||||
ClientDb::new(ctx, self.events_tx.clone())
|
||||
}
|
||||
|
||||
pub fn with_conn<F, T>(&self, func: F) -> T
|
||||
where
|
||||
F: FnOnce(&ClientDb) -> T,
|
||||
{
|
||||
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
|
||||
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
|
||||
let db = ClientDb::new(ctx, self.events_tx.clone());
|
||||
|
||||
func(&db)
|
||||
let conn = self.readers.get().expect("Failed to get a new DB connection from the pool");
|
||||
ClientDb::new(DbContext::new(ConnectionOrTx::Connection(conn)))
|
||||
}
|
||||
|
||||
/// Run `func` in a transaction on the writer connection.
|
||||
///
|
||||
/// Waits for any write in progress to commit first, and fails with a pool
|
||||
/// error if that takes longer than the pool's timeout. Do not call this
|
||||
/// from inside another `with_tx` closure: the inner call would wait for
|
||||
/// the outer transaction, which is waiting on it.
|
||||
///
|
||||
/// Model events for the writes are sent once the transaction commits.
|
||||
pub fn with_tx<T, E>(
|
||||
&self,
|
||||
func: impl FnOnce(&ClientDb) -> std::result::Result<T, E>,
|
||||
func: impl FnOnce(&WriteDb) -> std::result::Result<T, E>,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
.expect("Failed to start DB transaction");
|
||||
.map_err(crate::error::Error::SqlError)?;
|
||||
|
||||
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
|
||||
let db = ClientDb::new(ctx, self.events_tx.clone());
|
||||
let db =
|
||||
WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)), self.events_tx.clone());
|
||||
|
||||
match func(&db) {
|
||||
Ok(val) => {
|
||||
let events = db.into_events();
|
||||
tx.commit()
|
||||
.map_err(|e| GenericError(format!("Failed to commit transaction {e:?}")))?;
|
||||
for payload in events {
|
||||
let _ = self.events_tx.send(payload);
|
||||
}
|
||||
Ok(val)
|
||||
}
|
||||
Err(e) => {
|
||||
drop(db);
|
||||
tx.rollback()
|
||||
.map_err(|e| GenericError(format!("Failed to rollback transaction {e:?}")))?;
|
||||
Err(e)
|
||||
|
||||
@@ -277,7 +277,7 @@ pub fn get_workspace_export_resources(
|
||||
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
|
||||
data.resources.environments.append(
|
||||
&mut db
|
||||
.list_environments_ensure_base(workspace_id)?
|
||||
.list_environments(workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|e| include_private_environments || e.public)
|
||||
.collect(),
|
||||
|
||||
Reference in New Issue
Block a user