Ability to open workspace from directory, WorkspaceMeta, and many sync improvements

This commit is contained in:
Gregory Schier
2025-01-08 14:57:13 -08:00
parent 37671a50f2
commit cbc443075a
71 changed files with 1012 additions and 1844 deletions

View File

@@ -1,4 +1,4 @@
use crate::error::Error::{InvalidSyncFile, WorkspaceSyncNotConfigured};
use crate::error::Error::InvalidSyncFile;
use crate::error::Result;
use crate::models::SyncModel;
use chrono::Utc;
@@ -12,12 +12,11 @@ use tokio::fs;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use ts_rs::TS;
use yaak_models::models::{SyncState, Workspace};
use yaak_models::models::{SyncState, WorkspaceMeta};
use yaak_models::queries::{
delete_environment, delete_folder, delete_grpc_request, delete_http_request, delete_sync_state,
delete_workspace, get_workspace_export_resources, list_sync_states_for_workspace,
upsert_environment, upsert_folder, upsert_grpc_request, upsert_http_request, upsert_sync_state,
upsert_workspace, UpdateSource,
batch_upsert, delete_environment, delete_folder, delete_grpc_request, delete_http_request,
delete_sync_state, delete_workspace, get_workspace_export_resources, get_workspace_meta,
list_sync_states_for_workspace, upsert_sync_state, upsert_workspace_meta, UpdateSource,
};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -48,6 +47,19 @@ pub(crate) enum SyncOp {
},
}
impl SyncOp {
fn workspace_id(&self) -> String {
match self {
SyncOp::FsCreate { model } => model.workspace_id(),
SyncOp::FsUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::FsDelete { state, .. } => state.workspace_id.clone(),
SyncOp::DbCreate { fs } => fs.model.workspace_id(),
SyncOp::DbUpdate { state, .. } => state.workspace_id.clone(),
SyncOp::DbDelete { model, .. } => model.workspace_id(),
}
}
}
impl Display for SyncOp {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(
@@ -87,24 +99,23 @@ impl DbCandidate {
#[serde(rename_all = "camelCase", tag = "type")]
#[ts(export, export_to = "sync.ts")]
pub(crate) struct FsCandidate {
model: SyncModel,
rel_path: PathBuf,
checksum: String,
pub(crate) model: SyncModel,
pub(crate) rel_path: PathBuf,
pub(crate) checksum: String,
}
pub(crate) async fn get_db_candidates<R: Runtime>(
mgr: &impl Manager<R>,
workspace: &Workspace,
workspace_id: &str,
sync_dir: &Path,
) -> Result<Vec<DbCandidate>> {
let sync_dir = get_workspace_sync_dir(workspace)?;
let models: HashMap<_, _> =
workspace_models(mgr, workspace).await.into_iter().map(|m| (m.id(), m)).collect();
let sync_states: HashMap<_, _> =
list_sync_states_for_workspace(mgr, workspace.id.as_str(), sync_dir)
.await?
.into_iter()
.map(|s| (s.model_id.clone(), s))
.collect();
workspace_models(mgr, workspace_id).await.into_iter().map(|m| (m.id(), m)).collect();
let sync_states: HashMap<_, _> = list_sync_states_for_workspace(mgr, workspace_id, sync_dir)
.await?
.into_iter()
.map(|s| (s.model_id.clone(), s))
.collect();
// 1. Add candidates for models (created/modified/unmodified)
let mut candidates: Vec<DbCandidate> = models
@@ -139,14 +150,9 @@ pub(crate) async fn get_db_candidates<R: Runtime>(
Ok(candidates)
}
pub(crate) async fn get_fs_candidates(workspace: &Workspace) -> Result<Vec<FsCandidate>> {
let dir = match workspace.setting_sync_dir.clone() {
None => return Ok(Vec::new()),
Some(d) => d,
};
pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
// Ensure the root directory exists
fs::create_dir_all(dir.clone()).await?;
fs::create_dir_all(dir).await?;
let mut candidates = Vec::new();
let mut entries = fs::read_dir(dir).await?;
@@ -166,12 +172,6 @@ pub(crate) async fn get_fs_candidates(workspace: &Workspace) -> Result<Vec<FsCan
}
};
// Skip models belonging to different workspace
if model.workspace_id() != workspace.id.as_str() {
debug!("Skipping non-workspace file");
continue;
}
let rel_path = Path::new(&dir_entry.file_name()).to_path_buf();
candidates.push(FsCandidate {
rel_path,
@@ -254,7 +254,7 @@ pub(crate) fn compute_sync_ops(
}
}
(Some(DbCandidate::Added(model)), Some(_)) => {
// This would be super rare, so let's follow the user's intention
// This would be super rare (impossible?), so let's follow the user's intention
SyncOp::FsCreate {
model: model.to_owned(),
}
@@ -269,14 +269,17 @@ pub(crate) fn compute_sync_ops(
.collect()
}
async fn workspace_models<R: Runtime>(
mgr: &impl Manager<R>,
workspace: &Workspace,
) -> Vec<SyncModel> {
let workspace_id = workspace.id.as_str();
async fn workspace_models<R: Runtime>(mgr: &impl Manager<R>, workspace_id: &str) -> Vec<SyncModel> {
let resources = get_workspace_export_resources(mgr, vec![workspace_id]).await.resources;
let workspace = resources.workspaces.iter().find(|w| w.id == workspace_id);
let workspace = match workspace {
None => return Vec::new(),
Some(w) => w,
};
let mut sync_models = vec![SyncModel::Workspace(workspace.to_owned())];
for m in resources.environments {
sync_models.push(SyncModel::Environment(m));
}
@@ -295,7 +298,8 @@ async fn workspace_models<R: Runtime>(
pub(crate) async fn apply_sync_ops<R: Runtime>(
window: &WebviewWindow<R>,
workspace: &Workspace,
workspace_id: &str,
sync_dir: &Path,
sync_ops: Vec<SyncOp>,
) -> Result<Vec<SyncStateOp>> {
if sync_ops.is_empty() {
@@ -307,10 +311,145 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ")
);
let mut sync_state_ops = Vec::new();
let mut workspaces_to_upsert = Vec::new();
let mut environments_to_upsert = Vec::new();
let mut folders_to_upsert = Vec::new();
let mut http_requests_to_upsert = Vec::new();
let mut grpc_requests_to_upsert = Vec::new();
for op in sync_ops {
let op = apply_sync_op(window, workspace, &op).await?;
sync_state_ops.push(op);
// Only apply things if workspace ID matches
if op.workspace_id() != workspace_id {
continue;
}
sync_state_ops.push(match op {
SyncOp::FsCreate { model } => {
let rel_path = derive_model_filename(&model);
let abs_path = sync_dir.join(rel_path.clone());
let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?;
f.write_all(&content).await?;
SyncStateOp::Create {
model_id: model.id(),
checksum,
rel_path,
}
}
SyncOp::FsUpdate { model, state } => {
// Always write the existing path
let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?;
f.write_all(&content).await?;
SyncStateOp::Update {
state: state.to_owned(),
checksum,
rel_path: rel_path.to_owned(),
}
}
SyncOp::FsDelete {
state,
fs: fs_candidate,
} => match fs_candidate {
None => SyncStateOp::Delete {
state: state.to_owned(),
},
Some(_) => {
// Always delete the existing path
let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
fs::remove_file(&abs_path).await?;
SyncStateOp::Delete {
state: state.to_owned(),
}
}
},
SyncOp::DbCreate { fs } => {
let model_id = fs.model.id();
// Push updates to arrays so we can do them all in a single
// batch upsert to make foreign keys happy
match fs.model {
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
SyncModel::Environment(m) => environments_to_upsert.push(m),
SyncModel::Folder(m) => folders_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
};
SyncStateOp::Create {
model_id,
checksum: fs.checksum.to_owned(),
rel_path: fs.rel_path.to_owned(),
}
}
SyncOp::DbUpdate { state, fs } => {
// Push updates to arrays so we can do them all in a single
// batch upsert to make foreign keys happy
match fs.model {
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
SyncModel::Environment(m) => environments_to_upsert.push(m),
SyncModel::Folder(m) => folders_to_upsert.push(m),
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
}
SyncStateOp::Update {
state: state.to_owned(),
checksum: fs.checksum.to_owned(),
rel_path: fs.rel_path.to_owned(),
}
}
SyncOp::DbDelete { model, state } => {
delete_model(window, &model).await?;
SyncStateOp::Delete {
state: state.to_owned(),
}
}
});
}
let upserted_models = batch_upsert(
window,
workspaces_to_upsert,
environments_to_upsert,
folders_to_upsert,
http_requests_to_upsert,
grpc_requests_to_upsert,
)
.await?;
// Ensure we creat WorkspaceMeta models for each new workspace, with the appropriate sync dir
let sync_dir_string = sync_dir.to_string_lossy().to_string();
for workspace in upserted_models.workspaces {
let r = match get_workspace_meta(window, &workspace).await {
Ok(Some(m)) => {
if m.setting_sync_dir == Some(sync_dir_string.clone()) {
// We don't need to update if unchanged
continue;
}
let wm = WorkspaceMeta {
setting_sync_dir: Some(sync_dir.to_string_lossy().to_string()),
..m
};
upsert_workspace_meta(window, wm, &UpdateSource::Sync).await
}
Ok(None) => {
let wm = WorkspaceMeta {
workspace_id: workspace_id.to_string(),
setting_sync_dir: Some(sync_dir.to_string_lossy().to_string()),
..Default::default()
};
upsert_workspace_meta(window, wm, &UpdateSource::Sync).await
}
Err(e) => Err(e),
};
if let Err(e) = r {
warn!("Failed to upsert workspace meta for synced workspace {e:?}");
}
}
Ok(sync_state_ops)
}
@@ -331,178 +470,55 @@ pub(crate) enum SyncStateOp {
},
}
/// Flush a DB model to the filesystem
async fn apply_sync_op<R: Runtime>(
window: &WebviewWindow<R>,
workspace: &Workspace,
op: &SyncOp,
) -> Result<SyncStateOp> {
let sync_state_op = match op {
SyncOp::FsCreate { model } => {
let rel_path = derive_model_filename(&model);
let abs_path = derive_full_model_path(workspace, &model)?;
let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?;
f.write_all(&content).await?;
SyncStateOp::Create {
model_id: model.id(),
checksum,
rel_path,
}
}
SyncOp::FsUpdate { model, state } => {
// Always write the existing path
let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
let (content, checksum) = model.to_file_contents(&rel_path)?;
let mut f = File::create(&abs_path).await?;
f.write_all(&content).await?;
SyncStateOp::Update {
state: state.to_owned(),
checksum,
rel_path: rel_path.to_owned(),
}
}
SyncOp::FsDelete {
state,
fs: fs_candidate,
} => match fs_candidate {
None => SyncStateOp::Delete {
state: state.to_owned(),
},
Some(_) => {
// Always delete the existing path
let rel_path = Path::new(&state.rel_path);
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
fs::remove_file(&abs_path).await?;
SyncStateOp::Delete {
state: state.to_owned(),
}
}
},
SyncOp::DbCreate { fs } => {
upsert_model(window, &fs.model).await?;
SyncStateOp::Create {
model_id: fs.model.id(),
checksum: fs.checksum.to_owned(),
rel_path: fs.rel_path.to_owned(),
}
}
SyncOp::DbUpdate { state, fs } => {
upsert_model(window, &fs.model).await?;
SyncStateOp::Update {
state: state.to_owned(),
checksum: fs.checksum.to_owned(),
rel_path: fs.rel_path.to_owned(),
}
}
SyncOp::DbDelete { model, state } => {
delete_model(window, model).await?;
SyncStateOp::Delete {
state: state.to_owned(),
}
}
};
Ok(sync_state_op)
}
pub(crate) async fn apply_sync_state_ops<R: Runtime>(
window: &WebviewWindow<R>,
workspace: &Workspace,
workspace_id: &str,
sync_dir: &Path,
ops: Vec<SyncStateOp>,
) -> Result<()> {
for op in ops {
apply_sync_state_op(window, workspace, op).await?
}
Ok(())
}
async fn apply_sync_state_op<R: Runtime>(
window: &WebviewWindow<R>,
workspace: &Workspace,
op: SyncStateOp,
) -> Result<()> {
let dir_path = get_workspace_sync_dir(workspace)?;
match op {
SyncStateOp::Create {
checksum,
rel_path,
model_id,
} => {
let sync_state = SyncState {
workspace_id: workspace.to_owned().id,
match op {
SyncStateOp::Create {
checksum,
rel_path,
model_id,
} => {
let sync_state = SyncState {
workspace_id: workspace_id.to_string(),
model_id,
checksum,
sync_dir: sync_dir.to_str().unwrap().to_string(),
rel_path: rel_path.to_str().unwrap().to_string(),
flushed_at: Utc::now().naive_utc(),
..Default::default()
};
upsert_sync_state(window, sync_state).await?;
}
SyncStateOp::Update {
state: sync_state,
checksum,
sync_dir: dir_path.to_str().unwrap().to_string(),
rel_path: rel_path.to_str().unwrap().to_string(),
flushed_at: Utc::now().naive_utc(),
..Default::default()
};
upsert_sync_state(window, sync_state).await?;
}
SyncStateOp::Update {
state: sync_state,
checksum,
rel_path,
} => {
let sync_state = SyncState {
checksum,
sync_dir: dir_path.to_str().unwrap().to_string(),
rel_path: rel_path.to_str().unwrap().to_string(),
flushed_at: Utc::now().naive_utc(),
..sync_state
};
upsert_sync_state(window, sync_state).await?;
}
SyncStateOp::Delete { state } => {
delete_sync_state(window, state.id.as_str()).await?;
rel_path,
} => {
let sync_state = SyncState {
checksum,
sync_dir: sync_dir.to_str().unwrap().to_string(),
rel_path: rel_path.to_str().unwrap().to_string(),
flushed_at: Utc::now().naive_utc(),
..sync_state
};
upsert_sync_state(window, sync_state).await?;
}
SyncStateOp::Delete { state } => {
delete_sync_state(window, state.id.as_str()).await?;
}
}
}
Ok(())
}
pub(crate) fn get_workspace_sync_dir(workspace: &Workspace) -> Result<PathBuf> {
let workspace_id = workspace.to_owned().id;
match workspace.setting_sync_dir.to_owned() {
Some(d) => Ok(Path::new(&d).to_path_buf()),
None => Err(WorkspaceSyncNotConfigured(workspace_id)),
}
}
fn derive_full_model_path(workspace: &Workspace, m: &SyncModel) -> Result<PathBuf> {
let dir = get_workspace_sync_dir(workspace)?;
Ok(dir.join(derive_model_filename(m)))
}
fn derive_model_filename(m: &SyncModel) -> PathBuf {
let rel = format!("yaak.2.{}.yaml", m.id());
let rel = Path::new(&rel).to_path_buf();
// Ensure parent dir exists
rel
}
async fn upsert_model<R: Runtime>(window: &WebviewWindow<R>, m: &SyncModel) -> Result<()> {
match m {
SyncModel::Workspace(m) => {
upsert_workspace(window, m.to_owned(), &UpdateSource::Sync).await?;
}
SyncModel::Environment(m) => {
upsert_environment(window, m.to_owned(), &UpdateSource::Sync).await?;
}
SyncModel::Folder(m) => {
upsert_folder(window, m.to_owned(), &UpdateSource::Sync).await?;
}
SyncModel::HttpRequest(m) => {
upsert_http_request(window, m.to_owned(), &UpdateSource::Sync).await?;
}
SyncModel::GrpcRequest(m) => {
upsert_grpc_request(window, m.to_owned(), &UpdateSource::Sync).await?;
}
};
Ok(())
let rel = format!("yaak.{}.yaml", m.id());
Path::new(&rel).to_path_buf()
}
async fn delete_model<R: Runtime>(window: &WebviewWindow<R>, model: &SyncModel) -> Result<()> {