1use crate::ids::WorkspaceId;
2use anyhow::{Result, bail};
3use serde_json::json;
4use sqlx::{Row, SqliteConnection};
5
6use crate::change_log::op_type;
7use crate::db::{Database, begin_immediate, insert_change};
8use crate::ids::now;
9use crate::projects::normalize_key;
10
11pub const DEFAULT_WORKSPACE_ID: &str = "0000000000000000";
12
13pub fn default_workspace_id() -> WorkspaceId {
14 DEFAULT_WORKSPACE_ID
15 .parse()
16 .expect("valid default workspace ID")
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Workspace {
21 pub id: WorkspaceId,
22 pub key: String,
23 pub name: String,
24}
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct WorkspaceContents {
30 pub tasks: u32,
31 pub open_tasks: u32,
32 pub recurrences: u32,
33 pub conflicts: u32,
34}
35
36impl WorkspaceContents {
37 pub fn is_empty(&self) -> bool {
38 self.tasks == 0 && self.recurrences == 0 && self.conflicts == 0
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct WorkspaceOpenSummary {
44 pub workspace: Workspace,
45 pub open_task_count: u32,
46}
47
48impl Default for Workspace {
49 fn default() -> Self {
50 Self {
51 id: DEFAULT_WORKSPACE_ID
52 .parse()
53 .expect("valid default workspace ID"),
54 key: "default".to_string(),
55 name: "default".to_string(),
56 }
57 }
58}
59
60impl Database {
61 pub async fn list_workspaces(&self) -> Result<Vec<Workspace>> {
62 let mut conn = self.acquire_reader().await?;
63 list_workspaces(&mut conn).await
64 }
65
66 pub async fn list_workspace_open_summaries(&self) -> Result<Vec<WorkspaceOpenSummary>> {
67 let mut conn = self.acquire_reader().await?;
68 let rows = sqlx::query(
69 "SELECT w.id, w.key, w.name,
70 COALESCE(SUM(CASE WHEN t.deleted = 0
71 AND t.status NOT IN ('done', 'canceled') THEN 1 ELSE 0 END), 0)
72 AS open_task_count
73 FROM workspaces w
74 LEFT JOIN tasks t ON t.workspace_id = w.id
75 WHERE w.archived = 0
76 GROUP BY w.id, w.key, w.name
77 ORDER BY w.key",
78 )
79 .fetch_all(&mut *conn)
80 .await?;
81 Ok(rows
82 .into_iter()
83 .map(|row| WorkspaceOpenSummary {
84 workspace: Workspace {
85 id: row.get("id"),
86 key: row.get("key"),
87 name: row.get("name"),
88 },
89 open_task_count: u32::try_from(row.get::<i64, _>("open_task_count"))
90 .unwrap_or(u32::MAX),
91 })
92 .collect())
93 }
94
95 pub async fn find_workspace(&self, name_or_key: &str) -> Result<Option<Workspace>> {
96 let mut conn = self.acquire_reader().await?;
97 find_workspace(&mut conn, name_or_key).await
98 }
99
100 pub async fn workspace_for_id(&self, workspace_id: &WorkspaceId) -> Result<Workspace> {
101 let mut conn = self.acquire_reader().await?;
102 workspace_for_id(&mut conn, workspace_id).await
103 }
104
105 pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<Workspace> {
106 self.resolve_required_workspace(name_or_key, "workspace")
107 .await
108 }
109
110 pub async fn resolve_required_workspace(
111 &self,
112 name_or_key: &str,
113 source: &str,
114 ) -> Result<Workspace> {
115 let mut conn = self.acquire_reader().await?;
116 resolve_required_workspace(&mut conn, name_or_key, source).await
117 }
118
119 pub async fn create_workspace(&self, name: &str) -> Result<Workspace> {
120 let mut conn = self.acquire_writer().await?;
121 let mut tx = begin_immediate(&mut conn).await?;
122 let workspace = create_workspace(&mut tx, name).await?;
123 tx.commit().await?;
124 Ok(workspace)
125 }
126
127 pub async fn rename_workspace(&self, workspace_ref: &str, new_name: &str) -> Result<Workspace> {
128 let mut conn = self.acquire_writer().await?;
129 let mut tx = begin_immediate(&mut conn).await?;
130 let workspace = rename_workspace(&mut tx, workspace_ref, new_name).await?;
131 tx.commit().await?;
132 Ok(workspace)
133 }
134
135 pub async fn workspace_contents(
136 &self,
137 workspace_id: &WorkspaceId,
138 ) -> Result<WorkspaceContents> {
139 let mut conn = self.acquire_reader().await?;
140 workspace_contents(&mut conn, workspace_id).await
141 }
142
143 pub async fn delete_workspace(
147 &self,
148 workspace_id: &WorkspaceId,
149 config_entries: &[String],
150 force: bool,
151 ) -> Result<(Workspace, WorkspaceContents)> {
152 let mut conn = self.acquire_writer().await?;
153 let mut tx = begin_immediate(&mut conn).await?;
154 let deleted = delete_workspace(&mut tx, workspace_id, config_entries, force).await?;
155 tx.commit().await?;
156 Ok(deleted)
157 }
158}
159
160pub(crate) async fn ensure_default_workspace(conn: &mut SqliteConnection) -> Result<Workspace> {
161 if let Some(row) = sqlx::query("SELECT id, key, name FROM workspaces WHERE id = ?")
162 .bind(DEFAULT_WORKSPACE_ID)
163 .fetch_optional(&mut *conn)
164 .await?
165 {
166 return Ok(Workspace {
167 id: row.get("id"),
168 key: row.get("key"),
169 name: row.get("name"),
170 });
171 }
172 let id: WorkspaceId = DEFAULT_WORKSPACE_ID
173 .parse()
174 .expect("valid default workspace ID");
175 let ts = now();
176 sqlx::query("INSERT INTO workspaces(id, name, key, created_at, updated_at) VALUES (?, 'default', 'default', ?, ?)")
177 .bind(&id)
178 .bind(&ts)
179 .bind(&ts)
180 .execute(&mut *conn)
181 .await?;
182 Ok(Workspace {
183 id,
184 key: "default".to_string(),
185 name: "default".to_string(),
186 })
187}
188
189pub(crate) async fn list_workspaces(conn: &mut SqliteConnection) -> Result<Vec<Workspace>> {
190 let rows = sqlx::query("SELECT id, key, name FROM workspaces WHERE archived = 0 ORDER BY key")
191 .fetch_all(&mut *conn)
192 .await?;
193 Ok(rows
194 .into_iter()
195 .map(|row| Workspace {
196 id: row.get("id"),
197 key: row.get("key"),
198 name: row.get("name"),
199 })
200 .collect())
201}
202
203pub(crate) async fn find_workspace(
204 conn: &mut SqliteConnection,
205 name_or_key: &str,
206) -> Result<Option<Workspace>> {
207 let key = normalize_key(name_or_key);
208 let row = sqlx::query(
209 "SELECT id, key, name FROM workspaces
210 WHERE archived = 0 AND (key = ? OR lower(name) = lower(?))",
211 )
212 .bind(key)
213 .bind(name_or_key)
214 .fetch_optional(&mut *conn)
215 .await?;
216 Ok(row.map(workspace_from_row))
217}
218
219pub(crate) async fn workspace_for_id(
220 conn: &mut SqliteConnection,
221 workspace_id: &WorkspaceId,
222) -> Result<Workspace> {
223 fetch_workspace(conn, workspace_id, false).await
224}
225
226async fn fetch_workspace(
227 conn: &mut SqliteConnection,
228 workspace_id: &WorkspaceId,
229 include_archived: bool,
230) -> Result<Workspace> {
231 let row =
232 sqlx::query("SELECT id, key, name FROM workspaces WHERE id = ? AND (? OR archived = 0)")
233 .bind(workspace_id)
234 .bind(include_archived)
235 .fetch_optional(&mut *conn)
236 .await?;
237 match row {
238 Some(row) => Ok(workspace_from_row(row)),
239 None => Err(crate::error::CoreError::not_found(format!(
240 "error unknown-workspace-id id={workspace_id}"
241 ))
242 .into()),
243 }
244}
245
246pub(crate) async fn workspace_key_for_id(
247 conn: &mut SqliteConnection,
248 workspace_id: &WorkspaceId,
249) -> Result<String> {
250 Ok(workspace_for_id(conn, workspace_id).await?.key)
251}
252
253pub(crate) async fn stored_workspace_for_id(
256 conn: &mut SqliteConnection,
257 workspace_id: &WorkspaceId,
258) -> Result<Workspace> {
259 fetch_workspace(conn, workspace_id, true).await
260}
261
262pub(crate) async fn archive_workspace(
266 conn: &mut SqliteConnection,
267 workspace_id: &str,
268 deleted_at: &str,
269) -> Result<()> {
270 sqlx::query("UPDATE workspaces SET archived = 1, key = '~' || id, updated_at = ? WHERE id = ?")
271 .bind(deleted_at)
272 .bind(workspace_id)
273 .execute(&mut *conn)
274 .await?;
275 Ok(())
276}
277
278fn workspace_from_row(row: sqlx::sqlite::SqliteRow) -> Workspace {
279 Workspace {
280 id: row.get("id"),
281 key: row.get("key"),
282 name: row.get("name"),
283 }
284}
285
286pub(crate) async fn resolve_workspace(
287 conn: &mut SqliteConnection,
288 name_or_key: &str,
289) -> Result<Workspace> {
290 resolve_required_workspace(conn, name_or_key, "workspace").await
291}
292
293pub(crate) async fn resolve_required_workspace(
294 conn: &mut SqliteConnection,
295 name_or_key: &str,
296 source: &str,
297) -> Result<Workspace> {
298 if let Some(workspace) = find_workspace(conn, name_or_key).await? {
299 return Ok(workspace);
300 }
301 bail!(
302 "error unknown-workspace input={} source={} hint=\"create the workspace with aven workspace create\"",
303 name_or_key,
304 source
305 );
306}
307
308pub(crate) async fn create_workspace(conn: &mut SqliteConnection, name: &str) -> Result<Workspace> {
309 let key = normalize_key(name);
310 if key.is_empty() {
311 bail!("error invalid-workspace input={name}");
312 }
313 if find_workspace(conn, &key).await?.is_some() {
314 bail!("error workspace-exists key={key}");
315 }
316 let id = WorkspaceId::new();
317 let ts = now();
318 sqlx::query(
319 "INSERT INTO workspaces(id, name, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
320 )
321 .bind(&id)
322 .bind(name)
323 .bind(&key)
324 .bind(&ts)
325 .bind(&ts)
326 .execute(&mut *conn)
327 .await?;
328 insert_change(
329 conn,
330 "workspace",
331 id.as_str(),
332 None,
333 "create_workspace",
334 json!({ "key": key, "name": name, "created_at": ts }),
335 None,
336 )
337 .await?;
338 Ok(Workspace {
339 id,
340 key,
341 name: name.to_string(),
342 })
343}
344
345pub(crate) async fn rename_workspace(
346 conn: &mut SqliteConnection,
347 workspace_ref: &str,
348 new_name: &str,
349) -> Result<Workspace> {
350 let workspace = resolve_workspace(conn, workspace_ref).await?;
351 let new_key = normalize_key(new_name);
352 if new_key.is_empty() {
353 bail!("error invalid-workspace input={new_name}");
354 }
355 if new_key != workspace.key && find_workspace(conn, &new_key).await?.is_some() {
356 bail!("error workspace-exists key={new_key}");
357 }
358 let ts = now();
359 sqlx::query("UPDATE workspaces SET name = ?, key = ?, updated_at = ? WHERE id = ?")
360 .bind(new_name)
361 .bind(&new_key)
362 .bind(&ts)
363 .bind(&workspace.id)
364 .execute(&mut *conn)
365 .await?;
366 if workspace.name != new_name {
367 insert_change(
368 conn,
369 "workspace",
370 workspace.id.as_str(),
371 Some("name"),
372 "set_workspace_field",
373 json!({ "value": new_name }),
374 None,
375 )
376 .await?;
377 }
378 if workspace.key != new_key {
379 insert_change(
380 conn,
381 "workspace",
382 workspace.id.as_str(),
383 Some("key"),
384 "set_workspace_field",
385 json!({ "value": new_key }),
386 None,
387 )
388 .await?;
389 }
390 Ok(Workspace {
391 id: workspace.id,
392 key: new_key,
393 name: new_name.to_string(),
394 })
395}
396
397pub(crate) async fn workspace_contents(
398 conn: &mut SqliteConnection,
399 workspace_id: &WorkspaceId,
400) -> Result<WorkspaceContents> {
401 let row = sqlx::query(
402 "SELECT
403 (SELECT COUNT(*) FROM tasks WHERE workspace_id = ?1 AND deleted = 0) AS tasks,
404 (SELECT COUNT(*) FROM tasks WHERE workspace_id = ?1 AND deleted = 0
405 AND status NOT IN ('done', 'canceled')) AS open_tasks,
406 (SELECT COUNT(*) FROM recurrence_series WHERE workspace_id = ?1 AND deleted = 0
407 AND state != 'stopped') AS recurrences,
408 (SELECT COUNT(*) FROM conflicts WHERE workspace_id = ?1 AND resolved = 0)
409 AS conflicts",
410 )
411 .bind(workspace_id)
412 .fetch_one(&mut *conn)
413 .await?;
414 let count = |column: &str| u32::try_from(row.get::<i64, _>(column)).unwrap_or(u32::MAX);
415 Ok(WorkspaceContents {
416 tasks: count("tasks"),
417 open_tasks: count("open_tasks"),
418 recurrences: count("recurrences"),
419 conflicts: count("conflicts"),
420 })
421}
422
423pub(crate) async fn delete_workspace(
424 conn: &mut SqliteConnection,
425 workspace_id: &WorkspaceId,
426 config_entries: &[String],
427 force: bool,
428) -> Result<(Workspace, WorkspaceContents)> {
429 let protocol = crate::sync::protocol::replica_protocol(conn).await?;
430 if protocol < crate::sync::protocol::required_protocol(op_type::DELETE_WORKSPACE)? {
431 bail!(
432 "error workspace-delete-unavailable hint=\"a later Aven release enables workspace deletion\""
433 );
434 }
435 let workspace = workspace_for_id(conn, workspace_id).await?;
436 if workspace.id.as_str() == DEFAULT_WORKSPACE_ID {
437 bail!("error default-workspace-protected");
438 }
439 if !force && !config_entries.is_empty() {
440 bail!(
441 "error workspace-in-config key={} entries={} hint=\"edit the config or pass --force\"",
442 workspace.key,
443 config_entries.join(",")
444 );
445 }
446 let contents = workspace_contents(conn, workspace_id).await?;
447 if !force && !contents.is_empty() {
448 bail!(
449 "error workspace-not-empty key={} tasks={} open={} recurrences={} conflicts={} hint=\"pass --force to delete anyway\"",
450 workspace.key,
451 contents.tasks,
452 contents.open_tasks,
453 contents.recurrences,
454 contents.conflicts
455 );
456 }
457 let ts = now();
458 archive_workspace(conn, workspace.id.as_str(), &ts).await?;
459 insert_change(
460 conn,
461 "workspace",
462 workspace.id.as_str(),
463 None,
464 op_type::DELETE_WORKSPACE,
465 json!({ "deleted_at": ts }),
466 None,
467 )
468 .await?;
469 Ok((workspace, contents))
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::db::open_db;
476
477 #[tokio::test]
478 async fn fresh_database_has_default_workspace() {
479 let dir = tempfile::tempdir().unwrap();
480 let pool = open_db(&dir.path().join("test.db")).await.unwrap();
481 let mut conn = pool.acquire().await.unwrap();
482 let workspaces = list_workspaces(&mut conn).await.unwrap();
483 assert_eq!(workspaces.len(), 1);
484 assert_eq!(workspaces[0].key, "default");
485 }
486
487 async fn database() -> (tempfile::TempDir, Database) {
488 let dir = tempfile::tempdir().unwrap();
489 let database = crate::test_support::open_blank_database(&dir.path().join("test.sqlite"))
490 .await
491 .unwrap();
492 (dir, database)
493 }
494
495 fn draft(title: &str, status: &str) -> crate::operations::TaskDraft {
496 crate::operations::TaskDraft {
497 title: title.into(),
498 description: String::new(),
499 project: Some("app".into()),
500 status: status.into(),
501 priority: "none".into(),
502 source: crate::choices::TaskSource::Cli,
503 labels: vec![],
504 metadata: vec![],
505 available_at: None,
506 due_on: None,
507 is_epic: false,
508 }
509 }
510
511 async fn delete_change_count(database: &Database) -> i64 {
512 let mut conn = database.acquire_reader().await.unwrap();
513 sqlx::query_scalar("SELECT COUNT(*) FROM changes WHERE op_type = 'delete_workspace'")
514 .fetch_one(&mut *conn)
515 .await
516 .unwrap()
517 }
518
519 #[tokio::test]
520 async fn deletion_is_unavailable_until_the_replica_produces_protocol_19() {
521 let (_dir, database) = database().await;
522 let side = database.create_workspace("Side").await.unwrap();
523 let error = database
524 .delete_workspace(&side.id, &[], true)
525 .await
526 .unwrap_err()
527 .to_string();
528 assert!(
529 error.starts_with("error workspace-delete-unavailable"),
530 "{error}"
531 );
532 assert!(database.find_workspace("side").await.unwrap().is_some());
533 assert_eq!(delete_change_count(&database).await, 0);
534 }
535
536 #[tokio::test]
537 async fn deletion_refuses_in_order_then_archives_and_frees_the_name() {
538 let (_dir, database) = database().await;
539 crate::test_support::set_established_protocol(&database, 19)
540 .await
541 .unwrap();
542 let default = database.find_workspace("default").await.unwrap().unwrap();
543 let error = database
544 .delete_workspace(&default.id, &["workspace.default".into()], true)
545 .await
546 .unwrap_err();
547 assert_eq!(error.to_string(), "error default-workspace-protected");
548
549 let side = database.create_workspace("Side").await.unwrap();
550 database
551 .create_task(&side, draft("finished", "done"))
552 .await
553 .unwrap();
554 let error = database
555 .delete_workspace(&side.id, &["workspace.routes[0]".into()], false)
556 .await
557 .unwrap_err()
558 .to_string();
559 assert!(
560 error.starts_with("error workspace-in-config key=side entries=workspace.routes[0] "),
561 "{error}"
562 );
563 let error = database
565 .delete_workspace(&side.id, &[], false)
566 .await
567 .unwrap_err()
568 .to_string();
569 assert!(
570 error.starts_with(
571 "error workspace-not-empty key=side tasks=1 open=0 recurrences=0 conflicts=0 "
572 ),
573 "{error}"
574 );
575 assert_eq!(delete_change_count(&database).await, 0);
576
577 let (deleted, contents) = database
578 .delete_workspace(&side.id, &["workspace.routes[0]".into()], true)
579 .await
580 .unwrap();
581 assert_eq!(deleted.key, "side");
582 assert_eq!(contents.tasks, 1);
583 assert!(database.find_workspace("side").await.unwrap().is_none());
584 assert!(database.workspace_for_id(&side.id).await.is_err());
585 let mut conn = database.acquire_reader().await.unwrap();
586 let (key, name, archived, tasks): (String, String, i64, i64) = sqlx::query_as(
587 "SELECT key, name, archived,
588 (SELECT COUNT(*) FROM tasks WHERE workspace_id = workspaces.id)
589 FROM workspaces WHERE id = ?",
590 )
591 .bind(&side.id)
592 .fetch_one(&mut *conn)
593 .await
594 .unwrap();
595 drop(conn);
596 assert_eq!(
597 (key, name.as_str(), archived, tasks),
598 (format!("~{}", side.id), "Side", 1, 1)
599 );
600 assert_eq!(delete_change_count(&database).await, 1);
601
602 let recreated = database.create_workspace("Side").await.unwrap();
603 assert_ne!(recreated.id, side.id);
604 assert_eq!(recreated.key, "side");
605 }
606
607 #[tokio::test]
608 async fn unresolved_conflicts_alone_keep_a_workspace_from_being_empty() {
609 let (_dir, database) = database().await;
610 crate::test_support::set_established_protocol(&database, 19)
611 .await
612 .unwrap();
613 let side = database.create_workspace("Side").await.unwrap();
614 let mut conn = database.acquire_writer().await.unwrap();
615 sqlx::query(
616 "INSERT INTO conflicts(workspace_id, entity_type, entity_id, task_id, field,
617 local_value, remote_value, remote_change_id, variant_a, variant_b, created_at)
618 VALUES (?, 'task', 'gone', 'gone', 'title', 'a', 'b', 'c', 'a', 'b', 'now')",
619 )
620 .bind(&side.id)
621 .execute(&mut *conn)
622 .await
623 .unwrap();
624 drop(conn);
625 let contents = database.workspace_contents(&side.id).await.unwrap();
626 assert_eq!(
627 contents,
628 WorkspaceContents {
629 conflicts: 1,
630 ..Default::default()
631 }
632 );
633 assert!(
634 database
635 .delete_workspace(&side.id, &[], false)
636 .await
637 .is_err()
638 );
639 }
640
641 #[tokio::test]
642 async fn exported_deletion_history_imports_into_a_baseline_replica() {
643 let (_dir, source) = database().await;
644 crate::test_support::set_established_protocol(&source, 19)
645 .await
646 .unwrap();
647 let side = source.create_workspace("Side").await.unwrap();
648 source.delete_workspace(&side.id, &[], false).await.unwrap();
649 let export = source.export_data(now()).await.unwrap();
650
651 let (_target_dir, target) = database().await;
652 target.import_data(&export).await.unwrap();
653 assert!(target.find_workspace("side").await.unwrap().is_none());
654 assert_eq!(delete_change_count(&target).await, 1);
655 let report = target.database_integrity_report().await.unwrap();
656 assert!(report.checks.iter().all(|check| check.ok), "{report:#?}");
657 }
658}