use chrono::Utc; use redb::ReadableTable; use crate::db::{ Database, DbError, DbResult, WORKSPACES, WORKSPACE_SYNC_GROUPS, WORKSPACES_BY_SYNC_GROUP, }; use super::types::{ CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput, Workspace, WorkspaceSyncGroup, }; pub struct WorkspaceService { db: Database, } impl WorkspaceService { pub fn new(db: Database) -> Self { Self { db } } pub fn get_all(&self) -> DbResult> { let read_txn = self.db.begin_read()?; let table = read_txn.open_table(WORKSPACES)?; let mut workspaces = Vec::new(); for entry in table.iter()? { let (_, value) = entry?; let workspace: Workspace = serde_json::from_str(value.value()) .map_err(|e| DbError::Serialization(e.to_string()))?; workspaces.push(workspace); } workspaces.sort_by(|a, b| a.name.cmp(&b.name)); Ok(workspaces) } pub fn get(&self, id: &str) -> DbResult { let read_txn = self.db.begin_read()?; let table = read_txn.open_table(WORKSPACES)?; let value = table .get(id)? .ok_or_else(|| DbError::NotFound(format!("Workspace not found: {}", id)))?; let workspace: Workspace = serde_json::from_str(value.value()) .map_err(|e| DbError::Serialization(e.to_string()))?; Ok(workspace) } pub fn create(&self, input: CreateWorkspaceInput) -> DbResult { let mut workspace = Workspace::new(input.name, input.description); workspace.tags = input.tags; let json = serde_json::to_string(&workspace) .map_err(|e| DbError::Serialization(e.to_string()))?; let write_txn = self.db.begin_write()?; { let mut table = write_txn.open_table(WORKSPACES)?; table.insert(workspace.id.as_str(), json.as_str())?; } write_txn.commit()?; Ok(workspace) } pub fn update(&self, input: UpdateWorkspaceInput) -> DbResult { let mut workspace = self.get(&input.id)?; if let Some(name) = input.name { workspace.name = name; } if let Some(description) = input.description { workspace.description = description; } if let Some(tags) = input.tags { workspace.tags = tags; } if let Some(sync_group_id) = input.sync_group_id { workspace.sync_group_id = Some(sync_group_id); } workspace.updated_at = Utc::now(); // Write back let json = serde_json::to_string(&workspace) .map_err(|e| DbError::Serialization(e.to_string()))?; let write_txn = self.db.begin_write()?; { let mut table = write_txn.open_table(WORKSPACES)?; table.insert(workspace.id.as_str(), json.as_str())?; } write_txn.commit()?; Ok(workspace) } /// Delete a workspace pub fn delete(&self, id: &str) -> DbResult<()> { // First get the workspace to check sync_group_id let workspace = self.get(id)?; let sync_group_id = workspace.sync_group_id.clone(); let write_txn = self.db.begin_write()?; // Remove from workspaces table { let mut table = write_txn.open_table(WORKSPACES)?; table.remove(id)?; } // Remove from sync group index if applicable if let Some(group_id) = sync_group_id { self.remove_from_sync_index(&write_txn, &group_id, id)?; } write_txn.commit()?; Ok(()) } /// Helper to remove a workspace ID from the sync group index fn remove_from_sync_index( &self, write_txn: &redb::WriteTransaction, group_id: &str, workspace_id: &str, ) -> DbResult<()> { let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?; // Read current IDs let ids_json = match idx_table.get(group_id)? { Some(value) => value.value().to_string(), None => return Ok(()), }; let mut ids: Vec = serde_json::from_str(&ids_json) .map_err(|e| DbError::Serialization(e.to_string()))?; ids.retain(|i| i != workspace_id); let new_json = serde_json::to_string(&ids) .map_err(|e| DbError::Serialization(e.to_string()))?; idx_table.insert(group_id, new_json.as_str())?; Ok(()) } /// Helper to add a workspace ID to the sync group index fn add_to_sync_index( &self, write_txn: &redb::WriteTransaction, group_id: &str, workspace_id: &str, ) -> DbResult<()> { let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?; // Read current IDs or start with empty let ids_json = match idx_table.get(group_id)? { Some(value) => value.value().to_string(), None => "[]".to_string(), }; let mut ids: Vec = serde_json::from_str(&ids_json) .map_err(|e| DbError::Serialization(e.to_string()))?; if !ids.contains(&workspace_id.to_string()) { ids.push(workspace_id.to_string()); } let new_json = serde_json::to_string(&ids) .map_err(|e| DbError::Serialization(e.to_string()))?; idx_table.insert(group_id, new_json.as_str())?; Ok(()) } // ==================== Sync Group Operations ==================== /// Get all sync groups pub fn get_all_sync_groups(&self) -> DbResult> { let read_txn = self.db.begin_read()?; let table = read_txn.open_table(WORKSPACE_SYNC_GROUPS)?; let mut groups = Vec::new(); for entry in table.iter()? { let (_, value) = entry?; let group: WorkspaceSyncGroup = serde_json::from_str(value.value()) .map_err(|e| DbError::Serialization(e.to_string()))?; groups.push(group); } Ok(groups) } /// Get a sync group by ID pub fn get_sync_group(&self, id: &str) -> DbResult { let read_txn = self.db.begin_read()?; let table = read_txn.open_table(WORKSPACE_SYNC_GROUPS)?; let value = table .get(id)? .ok_or_else(|| DbError::NotFound(format!("Sync group not found: {}", id)))?; let group: WorkspaceSyncGroup = serde_json::from_str(value.value()) .map_err(|e| DbError::Serialization(e.to_string()))?; Ok(group) } /// Get sync group for a workspace pub fn get_sync_group_for_workspace( &self, workspace_id: &str, ) -> DbResult> { let workspace = self.get(workspace_id)?; match workspace.sync_group_id { Some(group_id) => Ok(Some(self.get_sync_group(&group_id)?)), None => Ok(None), } } /// Create a new sync group pub fn create_sync_group(&self, input: CreateSyncGroupInput) -> DbResult { let mut group = WorkspaceSyncGroup::new(input.name, input.workspace_ids.clone()); group.synced_variable_names = input.synced_variable_names; group.sync_secrets = input.sync_secrets; let json = serde_json::to_string(&group) .map_err(|e| DbError::Serialization(e.to_string()))?; let write_txn = self.db.begin_write()?; // Insert sync group { let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?; table.insert(group.id.as_str(), json.as_str())?; } // Update index { let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?; let ids_json = serde_json::to_string(&input.workspace_ids) .map_err(|e| DbError::Serialization(e.to_string()))?; idx_table.insert(group.id.as_str(), ids_json.as_str())?; } // Update each workspace's sync_group_id { let mut ws_table = write_txn.open_table(WORKSPACES)?; for ws_id in &input.workspace_ids { // Read workspace let ws_json = match ws_table.get(ws_id.as_str())? { Some(value) => value.value().to_string(), None => continue, }; let mut workspace: Workspace = serde_json::from_str(&ws_json) .map_err(|e| DbError::Serialization(e.to_string()))?; workspace.sync_group_id = Some(group.id.clone()); workspace.updated_at = Utc::now(); let new_ws_json = serde_json::to_string(&workspace) .map_err(|e| DbError::Serialization(e.to_string()))?; ws_table.insert(ws_id.as_str(), new_ws_json.as_str())?; } } write_txn.commit()?; Ok(group) } /// Update a sync group pub fn update_sync_group(&self, input: UpdateSyncGroupInput) -> DbResult { // Read existing let mut group = self.get_sync_group(&input.id)?; // Apply updates if let Some(name) = input.name { group.name = name; } if let Some(synced_variable_names) = input.synced_variable_names { group.synced_variable_names = synced_variable_names; } if let Some(sync_secrets) = input.sync_secrets { group.sync_secrets = sync_secrets; } group.updated_at = Utc::now(); // Write back let json = serde_json::to_string(&group) .map_err(|e| DbError::Serialization(e.to_string()))?; let write_txn = self.db.begin_write()?; { let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?; table.insert(group.id.as_str(), json.as_str())?; } write_txn.commit()?; Ok(group) } /// Delete a sync group pub fn delete_sync_group(&self, id: &str) -> DbResult<()> { // Get the sync group to find associated workspaces let group = self.get_sync_group(id)?; let workspace_ids = group.workspace_ids.clone(); let write_txn = self.db.begin_write()?; // Remove sync_group_id from all associated workspaces { let mut ws_table = write_txn.open_table(WORKSPACES)?; for ws_id in &workspace_ids { let ws_json = match ws_table.get(ws_id.as_str())? { Some(value) => value.value().to_string(), None => continue, }; let mut workspace: Workspace = serde_json::from_str(&ws_json) .map_err(|e| DbError::Serialization(e.to_string()))?; workspace.sync_group_id = None; workspace.updated_at = Utc::now(); let new_ws_json = serde_json::to_string(&workspace) .map_err(|e| DbError::Serialization(e.to_string()))?; ws_table.insert(ws_id.as_str(), new_ws_json.as_str())?; } } // Remove from sync groups table { let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?; table.remove(id)?; } // Remove from index { let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?; idx_table.remove(id)?; } write_txn.commit()?; Ok(()) } /// Get workspaces by sync group pub fn get_workspaces_by_sync_group(&self, sync_group_id: &str) -> DbResult> { let read_txn = self.db.begin_read()?; let idx_table = read_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?; let workspace_ids: Vec = match idx_table.get(sync_group_id)? { Some(value) => serde_json::from_str(value.value()) .map_err(|e| DbError::Serialization(e.to_string()))?, None => return Ok(Vec::new()), }; drop(idx_table); drop(read_txn); let mut workspaces = Vec::new(); for ws_id in workspace_ids { if let Ok(workspace) = self.get(&ws_id) { workspaces.push(workspace); } } Ok(workspaces) } /// Add a workspace to a sync group pub fn add_workspace_to_sync_group( &self, sync_group_id: &str, workspace_id: &str, ) -> DbResult<()> { // Read existing data let mut group = self.get_sync_group(sync_group_id)?; let mut workspace = self.get(workspace_id)?; // Update in memory if !group.workspace_ids.contains(&workspace_id.to_string()) { group.workspace_ids.push(workspace_id.to_string()); group.updated_at = Utc::now(); } workspace.sync_group_id = Some(sync_group_id.to_string()); workspace.updated_at = Utc::now(); // Serialize let group_json = serde_json::to_string(&group) .map_err(|e| DbError::Serialization(e.to_string()))?; let ws_json = serde_json::to_string(&workspace) .map_err(|e| DbError::Serialization(e.to_string()))?; // Write all changes let write_txn = self.db.begin_write()?; { let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?; table.insert(sync_group_id, group_json.as_str())?; } self.add_to_sync_index(&write_txn, sync_group_id, workspace_id)?; { let mut ws_table = write_txn.open_table(WORKSPACES)?; ws_table.insert(workspace_id, ws_json.as_str())?; } write_txn.commit()?; Ok(()) } /// Remove a workspace from a sync group pub fn remove_workspace_from_sync_group( &self, sync_group_id: &str, workspace_id: &str, ) -> DbResult<()> { // Read existing data let mut group = self.get_sync_group(sync_group_id)?; let mut workspace = self.get(workspace_id)?; // Update in memory group.workspace_ids.retain(|id| id != workspace_id); group.updated_at = Utc::now(); workspace.sync_group_id = None; workspace.updated_at = Utc::now(); // Serialize let group_json = serde_json::to_string(&group) .map_err(|e| DbError::Serialization(e.to_string()))?; let ws_json = serde_json::to_string(&workspace) .map_err(|e| DbError::Serialization(e.to_string()))?; // Write all changes let write_txn = self.db.begin_write()?; { let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?; table.insert(sync_group_id, group_json.as_str())?; } self.remove_from_sync_index(&write_txn, sync_group_id, workspace_id)?; { let mut ws_table = write_txn.open_table(WORKSPACES)?; ws_table.insert(workspace_id, ws_json.as_str())?; } write_txn.commit()?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::env::temp_dir; fn create_test_db() -> Database { let path = temp_dir().join(format!("resona_test_{}.redb", uuid::Uuid::new_v4())); Database::open_at(path).expect("Failed to create test database") } #[test] fn test_workspace_crud() { let db = create_test_db(); let service = WorkspaceService::new(db); let workspace = service .create(CreateWorkspaceInput { name: "Test Workspace".to_string(), description: "A test workspace".to_string(), tags: vec!["Development".to_string()], }) .expect("Failed to create workspace"); assert_eq!(workspace.name, "Test Workspace"); assert_eq!(workspace.tags, vec!["Development".to_string()]); let fetched = service.get(&workspace.id).expect("Failed to get workspace"); assert_eq!(fetched.id, workspace.id); let updated = service .update(UpdateWorkspaceInput { id: workspace.id.clone(), name: Some("Updated Workspace".to_string()), description: None, tags: None, sync_group_id: None, }) .expect("Failed to update workspace"); assert_eq!(updated.name, "Updated Workspace"); assert_eq!(updated.description, "A test workspace"); let all = service.get_all().expect("Failed to get all workspaces"); assert_eq!(all.len(), 1); service .delete(&workspace.id) .expect("Failed to delete workspace"); let all = service.get_all().expect("Failed to get all workspaces"); assert_eq!(all.len(), 0); } #[test] fn test_sync_groups() { let db = create_test_db(); let service = WorkspaceService::new(db); let ws1 = service .create(CreateWorkspaceInput { name: "Workspace 1".to_string(), description: "First workspace".to_string(), tags: vec!["Development".to_string()], }) .expect("Failed to create workspace 1"); let ws2 = service .create(CreateWorkspaceInput { name: "Workspace 2".to_string(), description: "Second workspace".to_string(), tags: vec!["Production".to_string()], }) .expect("Failed to create workspace 2"); // Create sync group let group = service .create_sync_group(CreateSyncGroupInput { name: "Test Sync Group".to_string(), workspace_ids: vec![ws1.id.clone(), ws2.id.clone()], synced_variable_names: vec!["API_KEY".to_string()], sync_secrets: false, }) .expect("Failed to create sync group"); // Verify workspaces are linked let ws1_updated = service.get(&ws1.id).expect("Failed to get workspace 1"); assert_eq!(ws1_updated.sync_group_id, Some(group.id.clone())); // Get workspaces by sync group let grouped = service .get_workspaces_by_sync_group(&group.id) .expect("Failed to get workspaces by sync group"); assert_eq!(grouped.len(), 2); // Delete sync group service .delete_sync_group(&group.id) .expect("Failed to delete sync group"); // Verify workspaces are unlinked let ws1_final = service.get(&ws1.id).expect("Failed to get workspace 1"); assert_eq!(ws1_final.sync_group_id, None); } }