Skip to main content

aven_core/
workspaces.rs

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/// What a workspace still holds when it is deleted. Any nonzero count except
27/// `open_tasks`, which is a subset of `tasks`, requires forcing the deletion.
28#[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    /// Archives a workspace and frees its key. `config_entries` names the
144    /// local configuration entries that refer to it; they and any remaining
145    /// contents refuse the deletion unless `force` is set.
146    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
253/// Looks up a workspace whether or not it is archived. Remote changes keep
254/// applying to a deleted workspace's retained contents.
255pub(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
262/// Hides a deleted workspace and gives it the key `~<id>`. The key derives from
263/// the ID alone so every replica agrees on it, and `normalize_key` never
264/// produces `~`, so it cannot collide with a live workspace key.
265pub(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        // Done tasks still count; projects alone do not.
564        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}