Skip to main content

aven_core/
api.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::Error as InternalError;
5use chrono::{NaiveDate, NaiveTime, Weekday};
6
7use crate::choices::{TaskPriority, TaskSource, TaskStatus};
8use crate::db::Database;
9use crate::ids::{ProjectId, TaskId, WorkspaceId};
10use crate::operations::{
11    RecurrenceSeriesDraft, RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate, TaskDraft,
12    TaskUpdate as InternalTaskUpdate,
13};
14use crate::query::{
15    MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
16    RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
17    RecurrenceHistoryKind as InternalRecurrenceHistoryKind,
18    RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
19    RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection, TaskFilters,
20    TaskListItem, TaskQueryMode, TaskSort,
21};
22use crate::recurrence::{
23    RecurrenceDuePolicy as InternalRecurrenceDuePolicy,
24    RecurrenceFrequency as InternalRecurrenceFrequency,
25    RecurrenceOutcome as InternalRecurrenceOutcome,
26    RecurrenceProjectionState as InternalRecurrenceProjectionState,
27    RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId,
28    RecurrenceSeriesState as InternalRecurrenceSeriesState, TimeZoneId, WeekdaySet,
29};
30use crate::sync::SyncSession;
31use crate::task_fields::TaskField;
32use crate::types::{RecurrenceOccurrence, RecurrenceSeries, Task};
33use crate::workspaces::Workspace;
34
35#[derive(Clone)]
36pub struct Store {
37    database: Database,
38}
39
40impl Store {
41    pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
42        let database = Database::open(path.as_ref())
43            .await
44            .map_err(Error::database_open)?;
45        Ok(Self { database })
46    }
47
48    pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
49        let root = crate::attachments::default_blob_dir(self.database.path());
50        let objects = root.join("objects").join("sha256");
51        let trash = root.join("trash");
52        let previews = root.join("cache").join("previews");
53        for directory in [&objects, &trash, &previews] {
54            std::fs::create_dir_all(directory)
55                .map_err(|error| Error::from_internal(error.into()))?;
56        }
57        Ok(StorageLayout {
58            root,
59            staging: objects.clone(),
60            objects,
61            trash,
62            previews,
63        })
64    }
65
66    pub async fn start_sync_session(
67        &self,
68        server: String,
69        auth_token: Option<String>,
70        page_budget: Option<usize>,
71    ) -> Result<SyncSession, Error> {
72        if !crate::sync::wire::sync_server_url_is_valid(&server) {
73            return Err(Error::new(
74                ErrorCode::Validation,
75                "invalid sync server URL".to_string(),
76            ));
77        }
78        SyncSession::start(self.database.clone(), server, auth_token, page_budget)
79            .await
80            .map_err(Error::from_internal)
81    }
82
83    pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
84        self.database
85            .list_workspaces()
86            .await
87            .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
88            .map_err(Error::from_internal)
89    }
90
91    pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
92        self.database
93            .find_workspace(name_or_key)
94            .await
95            .map_err(Error::from_internal)?
96            .map(WorkspaceRecord::from)
97            .ok_or_else(|| {
98                Error::new(
99                    ErrorCode::NotFound,
100                    format!("workspace not found: {name_or_key}"),
101                )
102            })
103    }
104
105    pub async fn create_task(
106        &self,
107        workspace_id: &WorkspaceId,
108        input: CreateTask,
109    ) -> Result<TaskRecord, Error> {
110        validate_project(&input.project)?;
111        validate_optional_date("available_at", input.available_at.as_deref())?;
112        validate_optional_date("due_on", input.due_on.as_deref())?;
113        let workspace = self.workspace(workspace_id).await?;
114        self.database
115            .create_task(
116                &workspace,
117                TaskDraft {
118                    title: input.title,
119                    description: input.description,
120                    project: Some(input.project),
121                    status: input.status.as_str().to_string(),
122                    priority: input.priority.as_str().to_string(),
123                    source: TaskSource::Api,
124                    labels: Vec::new(),
125                    available_at: input.available_at,
126                    due_on: input.due_on,
127                    is_epic: false,
128                },
129            )
130            .await
131            .map(|outcome| TaskRecord::from(outcome.task))
132            .map_err(Error::from_internal)
133    }
134
135    pub async fn update_task(
136        &self,
137        workspace_id: &WorkspaceId,
138        task_id: &TaskId,
139        input: UpdateTask,
140    ) -> Result<TaskUpdateResult, Error> {
141        if let Some(project) = input.project.as_deref() {
142            validate_project(project)?;
143        }
144        validate_date_update("available_at", &input.available_at)?;
145        validate_date_update("due_on", &input.due_on)?;
146        let workspace = self.workspace(workspace_id).await?;
147        self.fetch_task(workspace_id, task_id).await?;
148        self.database
149            .update_task(
150                &workspace,
151                task_id,
152                InternalTaskUpdate {
153                    title: input.title,
154                    description: input.description,
155                    project: input.project,
156                    status: input.status.map(|status| status.as_str().to_string()),
157                    priority: input.priority.map(|priority| priority.as_str().to_string()),
158                    available_at: input.available_at.into_internal(),
159                    due_on: input.due_on.into_internal(),
160                    ..InternalTaskUpdate::default()
161                },
162            )
163            .await
164            .map(|outcome| TaskUpdateResult {
165                task: TaskRecord::from(outcome.task),
166                changed: outcome.changed,
167            })
168            .map_err(Error::from_internal)
169    }
170
171    pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
172        self.workspace(workspace_id).await?;
173        let filters = TaskFilters {
174            exclude_epics: true,
175            ..TaskFilters::default()
176        };
177        self.database
178            .list_task_items(
179                workspace_id,
180                filters,
181                TaskQueryMode::Flat,
182                TaskSort::Created,
183                SortDirection::Asc,
184            )
185            .await
186            .map(|items| {
187                items
188                    .into_iter()
189                    .map(|item| TaskRecord::from(item.task))
190                    .collect()
191            })
192            .map_err(Error::from_internal)
193    }
194
195    pub async fn fetch_task(
196        &self,
197        workspace_id: &WorkspaceId,
198        task_id: &TaskId,
199    ) -> Result<TaskRecord, Error> {
200        let workspace = self.workspace(workspace_id).await?;
201        let mut connection = self
202            .database
203            .acquire()
204            .await
205            .map_err(Error::from_internal)?;
206        crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
207            .await
208            .map(TaskRecord::from)
209            .map_err(Error::from_internal)
210    }
211
212    pub async fn create_recurrence_series(
213        &self,
214        workspace_id: &WorkspaceId,
215        input: CreateRecurrenceSeries,
216    ) -> Result<RecurrenceCreateResult, Error> {
217        validate_project(&input.project)?;
218        let workspace = self.workspace(workspace_id).await?;
219        let schedule = input.schedule.into_internal()?;
220        self.database
221            .create_recurrence_series(
222                &workspace,
223                RecurrenceSeriesDraft {
224                    title: input.title,
225                    description: input.description,
226                    project: input.project,
227                    priority: input.priority.as_str().to_string(),
228                    initial_status: input.initial_status.as_str().to_string(),
229                    labels: input.labels,
230                    schedule,
231                },
232            )
233            .await
234            .map(|outcome| RecurrenceCreateResult {
235                series: RecurrenceSeriesRecord::from(outcome.series),
236                series_ref: outcome.series_ref,
237                occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
238                task: TaskRecord::from(outcome.task),
239            })
240            .map_err(Error::from_internal)
241    }
242
243    pub async fn update_recurrence_template(
244        &self,
245        workspace_id: &WorkspaceId,
246        series_id: &RecurrenceSeriesId,
247        input: UpdateRecurrenceTemplate,
248    ) -> Result<RecurrenceTemplateUpdateResult, Error> {
249        if let Some(project) = input.project.as_deref() {
250            validate_project(project)?;
251        }
252        let workspace = self.workspace(workspace_id).await?;
253        self.database
254            .update_recurrence_template(
255                &workspace,
256                series_id,
257                InternalRecurrenceTemplateUpdate {
258                    title: input.title,
259                    description: input.description,
260                    project: input.project,
261                    priority: input.priority.map(|value| value.as_str().to_string()),
262                    initial_status: input.initial_status.map(|value| value.as_str().to_string()),
263                    labels: input.labels,
264                    available_local_time: input.available_local_time.into_internal()?,
265                    due_policy: input.due_policy.map(Into::into),
266                },
267            )
268            .await
269            .map(|outcome| RecurrenceTemplateUpdateResult {
270                series: RecurrenceSeriesRecord::from(outcome.series),
271                changed: outcome.changed,
272            })
273            .map_err(Error::from_internal)
274    }
275
276    pub async fn resolve_recurrence_ref(
277        &self,
278        workspace_id: &WorkspaceId,
279        input: &str,
280    ) -> Result<RecurrenceRefResolution, Error> {
281        let workspace = self.workspace(workspace_id).await?;
282        let series = self
283            .database
284            .resolve_recurrence_ref(&workspace, input)
285            .await
286            .map_err(Error::from_internal)?;
287        let series_ref = self
288            .database
289            .recurrence_series_ref(workspace_id, &series.id)
290            .await
291            .map_err(Error::from_internal)?;
292        Ok(RecurrenceRefResolution {
293            series_id: series.id,
294            series_ref,
295        })
296    }
297
298    pub async fn list_recurrence_series(
299        &self,
300        workspace_id: &WorkspaceId,
301    ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
302        self.workspace(workspace_id).await?;
303        self.database
304            .list_recurrence_series(workspace_id)
305            .await
306            .map(|values| values.into_iter().map(Into::into).collect())
307            .map_err(Error::from_internal)
308    }
309
310    pub async fn show_recurrence_series(
311        &self,
312        workspace_id: &WorkspaceId,
313        input: &str,
314    ) -> Result<RecurrenceSeriesDetail, Error> {
315        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
316        self.database
317            .recurrence_series_detail(workspace_id, &resolution.series_id)
318            .await
319            .map(Into::into)
320            .map_err(Error::from_internal)
321    }
322
323    pub async fn recurrence_history(
324        &self,
325        workspace_id: &WorkspaceId,
326        input: &str,
327        offset: usize,
328        limit: usize,
329    ) -> Result<RecurrenceHistoryPage, Error> {
330        if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
331            return Err(Error::new(
332                ErrorCode::Validation,
333                format!(
334                    "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
335                ),
336            ));
337        }
338        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
339        self.database
340            .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
341            .await
342            .map(|page| RecurrenceHistoryPage {
343                series_ref: page.series_ref,
344                items: page.items.into_iter().map(Into::into).collect(),
345                offset: page.offset,
346                limit: page.limit,
347                total: page.total,
348                has_more: page.has_more,
349            })
350            .map_err(Error::from_internal)
351    }
352
353    pub async fn resolve_recurrence_occurrence(
354        &self,
355        workspace_id: &WorkspaceId,
356        task_id: &TaskId,
357        outcome: RecurrenceOutcome,
358    ) -> Result<RecurrenceResolveResult, Error> {
359        let workspace = self.workspace(workspace_id).await?;
360        self.database
361            .resolve_recurrence_occurrence(&workspace, task_id, outcome.into())
362            .await
363            .map(|value| RecurrenceResolveResult {
364                series: value.series.into(),
365                occurrence: value.resolved.into(),
366                task: value.task.into(),
367                successor: value.successor.map(Into::into),
368            })
369            .map_err(Error::from_internal)
370    }
371
372    pub async fn complete_recurrence_occurrence(
373        &self,
374        workspace_id: &WorkspaceId,
375        task_id: &TaskId,
376    ) -> Result<RecurrenceResolveResult, Error> {
377        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
378            .await
379    }
380
381    pub async fn skip_recurrence_occurrence(
382        &self,
383        workspace_id: &WorkspaceId,
384        task_id: &TaskId,
385    ) -> Result<RecurrenceResolveResult, Error> {
386        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
387            .await
388    }
389
390    pub async fn pause_recurrence_series(
391        &self,
392        workspace_id: &WorkspaceId,
393        series_id: &RecurrenceSeriesId,
394    ) -> Result<RecurrenceStateResult, Error> {
395        let workspace = self.workspace(workspace_id).await?;
396        self.database
397            .pause_recurrence_series(&workspace, series_id)
398            .await
399            .map(Into::into)
400            .map_err(Error::from_internal)
401    }
402
403    pub async fn resume_recurrence_series(
404        &self,
405        workspace_id: &WorkspaceId,
406        series_id: &RecurrenceSeriesId,
407    ) -> Result<RecurrenceStateResult, Error> {
408        let workspace = self.workspace(workspace_id).await?;
409        let at = chrono::DateTime::parse_from_rfc3339(&crate::ids::now())
410            .map_err(|error| Error::from_internal(error.into()))?
411            .with_timezone(&chrono::Utc);
412        self.database
413            .resume_recurrence_series(&workspace, series_id, at)
414            .await
415            .map(Into::into)
416            .map_err(Error::from_internal)
417    }
418
419    pub async fn stop_recurrence_series(
420        &self,
421        workspace_id: &WorkspaceId,
422        series_id: &RecurrenceSeriesId,
423        skip_current: bool,
424    ) -> Result<RecurrenceStateResult, Error> {
425        let workspace = self.workspace(workspace_id).await?;
426        self.database
427            .stop_recurrence_series(&workspace, series_id, skip_current)
428            .await
429            .map(Into::into)
430            .map_err(Error::from_internal)
431    }
432
433    pub async fn recurrence_task_report(
434        &self,
435        workspace_id: &WorkspaceId,
436        expand_recurring: bool,
437    ) -> Result<Vec<TaskSummary>, Error> {
438        self.workspace(workspace_id).await?;
439        self.database
440            .list_task_items(
441                workspace_id,
442                TaskFilters {
443                    exclude_epics: true,
444                    expand_recurring,
445                    ..TaskFilters::default()
446                },
447                TaskQueryMode::Flat,
448                TaskSort::Created,
449                SortDirection::Asc,
450            )
451            .await
452            .map(|items| items.into_iter().map(TaskSummary::from).collect())
453            .map_err(Error::from_internal)
454    }
455
456    pub async fn list_conflicts(
457        &self,
458        workspace_id: &WorkspaceId,
459    ) -> Result<Vec<ConflictSummary>, Error> {
460        let workspace = self.workspace(workspace_id).await?;
461        self.database
462            .list_conflicts(&workspace, None, None)
463            .await
464            .map_err(Error::from_internal)?
465            .into_iter()
466            .map(|conflict| {
467                Ok(ConflictSummary {
468                    task_id: conflict.task_id,
469                    task_title: conflict.title,
470                    project_key: conflict.project_key,
471                    project_prefix: conflict.project_prefix,
472                    field: ConflictField::from_task_field(TaskField::parse_or_unknown(
473                        &conflict.field,
474                    )?),
475                })
476            })
477            .collect::<Result<Vec<_>, InternalError>>()
478            .map_err(Error::from_internal)
479    }
480
481    pub async fn inspect_conflicts(
482        &self,
483        workspace_id: &WorkspaceId,
484        task_id: &TaskId,
485    ) -> Result<Vec<Conflict>, Error> {
486        let workspace = self.workspace(workspace_id).await?;
487        let details = self
488            .database
489            .task_conflicts(&workspace, task_id, None)
490            .await
491            .map_err(Error::from_internal)?;
492        let mut conflicts = Vec::with_capacity(details.len());
493        for detail in details {
494            let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
495            let local_value = self
496                .database
497                .conflict_display_value(workspace_id, field.as_str(), &detail.local_value)
498                .await
499                .map_err(Error::from_internal)?;
500            let remote_value = self
501                .database
502                .conflict_display_value(workspace_id, field.as_str(), &detail.remote_value)
503                .await
504                .map_err(Error::from_internal)?;
505            conflicts.push(Conflict {
506                task_id: task_id.clone(),
507                field: ConflictField::from_task_field(field),
508                local_value,
509                remote_value,
510            });
511        }
512        Ok(conflicts)
513    }
514
515    pub async fn resolve_conflict(
516        &self,
517        workspace_id: &WorkspaceId,
518        task_id: &TaskId,
519        field: ConflictField,
520        choice: ConflictChoice,
521    ) -> Result<TaskRecord, Error> {
522        let workspace = self.workspace(workspace_id).await?;
523        let field_name = field.as_str();
524        let choice = match choice {
525            ConflictChoice::Local => crate::operations::ConflictValueChoice::Local,
526            ConflictChoice::Remote => crate::operations::ConflictValueChoice::Remote,
527        };
528        let mut connection = self
529            .database
530            .acquire()
531            .await
532            .map_err(Error::from_internal)?;
533        crate::operations::resolve_conflict_choice(
534            &mut connection,
535            &workspace,
536            task_id,
537            field_name,
538            choice,
539        )
540        .await
541        .map(|outcome| TaskRecord::from(outcome.task))
542        .map_err(Error::from_internal)
543    }
544
545    async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
546        self.database
547            .list_workspaces()
548            .await
549            .map_err(Error::from_internal)?
550            .into_iter()
551            .find(|workspace| workspace.id == *workspace_id)
552            .ok_or_else(|| {
553                Error::new(
554                    ErrorCode::NotFound,
555                    format!("workspace not found: {workspace_id}"),
556                )
557            })
558    }
559}
560
561fn validate_project(project: &str) -> Result<(), Error> {
562    if crate::projects::normalize_key(project).is_empty() {
563        return Err(Error::new(
564            ErrorCode::Validation,
565            "project must contain at least one letter or number".to_string(),
566        ));
567    }
568    Ok(())
569}
570
571fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
572    let Some(value) = value else {
573        return Ok(());
574    };
575    if value.is_empty() {
576        return Err(Error::new(
577            ErrorCode::Validation,
578            format!("{field} must be absent or contain a date"),
579        ));
580    }
581    let result = match field {
582        "available_at" => crate::time_validation::validate_available_at_value(value),
583        "due_on" => crate::time_validation::validate_due_on_value(value),
584        _ => unreachable!("consumer API validates only task dates"),
585    };
586    result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
587}
588
589fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
590    match update {
591        OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
592        OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
593    }
594}
595
596fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
597    NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
598        Error::new(
599            ErrorCode::Validation,
600            format!("{field} must use YYYY-MM-DD"),
601        )
602    })
603}
604
605fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
606    NaiveTime::parse_from_str(value, "%H:%M")
607        .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
608        .map_err(|_| {
609            Error::new(
610                ErrorCode::Validation,
611                format!("{field} must use HH:MM or HH:MM:SS"),
612            )
613        })
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct StorageLayout {
618    pub root: PathBuf,
619    pub objects: PathBuf,
620    pub staging: PathBuf,
621    pub trash: PathBuf,
622    pub previews: PathBuf,
623}
624
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct WorkspaceRecord {
627    pub id: WorkspaceId,
628    pub key: String,
629    pub name: String,
630}
631
632impl From<Workspace> for WorkspaceRecord {
633    fn from(workspace: Workspace) -> Self {
634        Self {
635            id: workspace.id,
636            key: workspace.key,
637            name: workspace.name,
638        }
639    }
640}
641
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct CreateTask {
644    pub title: String,
645    pub description: String,
646    pub project: String,
647    pub status: TaskStatus,
648    pub priority: TaskPriority,
649    pub available_at: Option<String>,
650    pub due_on: Option<String>,
651}
652
653#[derive(Debug, Clone, PartialEq, Eq, Default)]
654pub struct UpdateTask {
655    pub title: Option<String>,
656    pub description: Option<String>,
657    pub project: Option<String>,
658    pub status: Option<TaskStatus>,
659    pub priority: Option<TaskPriority>,
660    pub available_at: OptionalDateUpdate,
661    pub due_on: OptionalDateUpdate,
662}
663
664#[derive(Debug, Clone, PartialEq, Eq, Default)]
665pub enum OptionalDateUpdate {
666    #[default]
667    Unchanged,
668    Set(String),
669    Clear,
670}
671
672impl OptionalDateUpdate {
673    fn into_internal(self) -> Option<Option<String>> {
674        match self {
675            Self::Unchanged => None,
676            Self::Set(value) => Some(Some(value)),
677            Self::Clear => Some(None),
678        }
679    }
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
683pub enum RecurrenceFrequency {
684    Daily,
685    Weekly,
686    Monthly,
687}
688
689impl From<RecurrenceFrequency> for InternalRecurrenceFrequency {
690    fn from(value: RecurrenceFrequency) -> Self {
691        match value {
692            RecurrenceFrequency::Daily => Self::Daily,
693            RecurrenceFrequency::Weekly => Self::Weekly,
694            RecurrenceFrequency::Monthly => Self::Monthly,
695        }
696    }
697}
698
699impl From<InternalRecurrenceFrequency> for RecurrenceFrequency {
700    fn from(value: InternalRecurrenceFrequency) -> Self {
701        match value {
702            InternalRecurrenceFrequency::Daily => Self::Daily,
703            InternalRecurrenceFrequency::Weekly => Self::Weekly,
704            InternalRecurrenceFrequency::Monthly => Self::Monthly,
705        }
706    }
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq)]
710pub enum RecurrenceWeekday {
711    Monday,
712    Tuesday,
713    Wednesday,
714    Thursday,
715    Friday,
716    Saturday,
717    Sunday,
718}
719
720impl From<RecurrenceWeekday> for Weekday {
721    fn from(value: RecurrenceWeekday) -> Self {
722        match value {
723            RecurrenceWeekday::Monday => Self::Mon,
724            RecurrenceWeekday::Tuesday => Self::Tue,
725            RecurrenceWeekday::Wednesday => Self::Wed,
726            RecurrenceWeekday::Thursday => Self::Thu,
727            RecurrenceWeekday::Friday => Self::Fri,
728            RecurrenceWeekday::Saturday => Self::Sat,
729            RecurrenceWeekday::Sunday => Self::Sun,
730        }
731    }
732}
733
734impl From<Weekday> for RecurrenceWeekday {
735    fn from(value: Weekday) -> Self {
736        match value {
737            Weekday::Mon => Self::Monday,
738            Weekday::Tue => Self::Tuesday,
739            Weekday::Wed => Self::Wednesday,
740            Weekday::Thu => Self::Thursday,
741            Weekday::Fri => Self::Friday,
742            Weekday::Sat => Self::Saturday,
743            Weekday::Sun => Self::Sunday,
744        }
745    }
746}
747
748#[derive(Debug, Clone, PartialEq, Eq)]
749pub struct RecurrenceRule {
750    pub frequency: RecurrenceFrequency,
751    pub interval: u32,
752    pub weekdays: Vec<RecurrenceWeekday>,
753}
754
755impl RecurrenceRule {
756    fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
757        InternalRecurrenceRule::new(
758            self.frequency.into(),
759            self.interval,
760            WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
761        )
762        .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
763    }
764}
765
766impl From<InternalRecurrenceRule> for RecurrenceRule {
767    fn from(value: InternalRecurrenceRule) -> Self {
768        Self {
769            frequency: value.frequency().into(),
770            interval: value.interval(),
771            weekdays: value.weekdays_set().iter().map(Into::into).collect(),
772        }
773    }
774}
775
776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
777pub enum RecurrenceDuePolicy {
778    SameDay,
779    None,
780}
781
782impl From<RecurrenceDuePolicy> for InternalRecurrenceDuePolicy {
783    fn from(value: RecurrenceDuePolicy) -> Self {
784        match value {
785            RecurrenceDuePolicy::SameDay => Self::SameDay,
786            RecurrenceDuePolicy::None => Self::None,
787        }
788    }
789}
790
791impl From<InternalRecurrenceDuePolicy> for RecurrenceDuePolicy {
792    fn from(value: InternalRecurrenceDuePolicy) -> Self {
793        match value {
794            InternalRecurrenceDuePolicy::SameDay => Self::SameDay,
795            InternalRecurrenceDuePolicy::None => Self::None,
796        }
797    }
798}
799
800#[derive(Debug, Clone, Copy, PartialEq, Eq)]
801pub enum RecurrenceSeriesState {
802    Active,
803    Paused,
804    Stopped,
805}
806
807impl From<InternalRecurrenceSeriesState> for RecurrenceSeriesState {
808    fn from(value: InternalRecurrenceSeriesState) -> Self {
809        match value {
810            InternalRecurrenceSeriesState::Active => Self::Active,
811            InternalRecurrenceSeriesState::Paused => Self::Paused,
812            InternalRecurrenceSeriesState::Stopped => Self::Stopped,
813        }
814    }
815}
816
817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
818pub enum RecurrenceOutcome {
819    Completed,
820    Skipped,
821}
822
823impl From<RecurrenceOutcome> for InternalRecurrenceOutcome {
824    fn from(value: RecurrenceOutcome) -> Self {
825        match value {
826            RecurrenceOutcome::Completed => Self::Completed,
827            RecurrenceOutcome::Skipped => Self::Skipped,
828        }
829    }
830}
831
832impl From<InternalRecurrenceOutcome> for RecurrenceOutcome {
833    fn from(value: InternalRecurrenceOutcome) -> Self {
834        match value {
835            InternalRecurrenceOutcome::Completed => Self::Completed,
836            InternalRecurrenceOutcome::Skipped => Self::Skipped,
837        }
838    }
839}
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum RecurrenceProjectionState {
843    Projected,
844    Resolved,
845    Archived,
846}
847
848impl From<InternalRecurrenceProjectionState> for RecurrenceProjectionState {
849    fn from(value: InternalRecurrenceProjectionState) -> Self {
850        match value {
851            InternalRecurrenceProjectionState::Projected => Self::Projected,
852            InternalRecurrenceProjectionState::Resolved => Self::Resolved,
853            InternalRecurrenceProjectionState::Archived => Self::Archived,
854        }
855    }
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct RecurrenceScheduleInput {
860    pub rule: RecurrenceRule,
861    pub timezone: String,
862    pub start_on: String,
863    pub available_local_time: Option<String>,
864    pub due_policy: RecurrenceDuePolicy,
865}
866
867impl RecurrenceScheduleInput {
868    fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
869        let timezone = self
870            .timezone
871            .parse::<TimeZoneId>()
872            .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
873        let start_on = parse_date("start_on", &self.start_on)?;
874        let available_local_time = self
875            .available_local_time
876            .map(|value| parse_local_time("available_local_time", &value))
877            .transpose()?;
878        Ok(RecurrenceSchedule::new(
879            self.rule.into_internal()?,
880            timezone,
881            start_on,
882            available_local_time,
883            self.due_policy.into(),
884        ))
885    }
886}
887
888#[derive(Debug, Clone, PartialEq, Eq)]
889pub struct CreateRecurrenceSeries {
890    pub title: String,
891    pub description: String,
892    pub project: String,
893    pub priority: TaskPriority,
894    pub initial_status: TaskStatus,
895    pub labels: Vec<String>,
896    pub schedule: RecurrenceScheduleInput,
897}
898
899#[derive(Debug, Clone, PartialEq, Eq, Default)]
900pub enum OptionalLocalTimeUpdate {
901    #[default]
902    Unchanged,
903    Set(String),
904    Clear,
905}
906
907impl OptionalLocalTimeUpdate {
908    fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
909        match self {
910            Self::Unchanged => Ok(None),
911            Self::Set(value) => Ok(Some(Some(parse_local_time(
912                "available_local_time",
913                &value,
914            )?))),
915            Self::Clear => Ok(Some(None)),
916        }
917    }
918}
919
920#[derive(Debug, Clone, PartialEq, Eq, Default)]
921pub struct UpdateRecurrenceTemplate {
922    pub title: Option<String>,
923    pub description: Option<String>,
924    pub project: Option<String>,
925    pub priority: Option<TaskPriority>,
926    pub initial_status: Option<TaskStatus>,
927    pub labels: Option<Vec<String>>,
928    pub available_local_time: OptionalLocalTimeUpdate,
929    pub due_policy: Option<RecurrenceDuePolicy>,
930}
931
932#[derive(Debug, Clone, PartialEq, Eq)]
933pub struct RecurrenceSeriesRecord {
934    pub workspace_id: WorkspaceId,
935    pub id: RecurrenceSeriesId,
936    pub title: String,
937    pub description: String,
938    pub project_id: ProjectId,
939    pub priority: TaskPriority,
940    pub initial_status: TaskStatus,
941    pub rule: RecurrenceRule,
942    pub timezone: String,
943    pub start_on: String,
944    pub available_local_time: Option<String>,
945    pub due_policy: RecurrenceDuePolicy,
946    pub state: RecurrenceSeriesState,
947    pub stopped_at: Option<String>,
948    pub created_at: String,
949    pub updated_at: String,
950}
951
952impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
953    fn from(value: RecurrenceSeries) -> Self {
954        Self {
955            workspace_id: value.workspace_id,
956            id: value.id,
957            title: value.title,
958            description: value.description,
959            project_id: value.project_id,
960            priority: value.priority,
961            initial_status: value.initial_status,
962            rule: value.rule.into(),
963            timezone: value.timezone.to_string(),
964            start_on: value.start_on.to_string(),
965            available_local_time: value
966                .available_local_time
967                .map(|time| time.format("%H:%M:%S").to_string()),
968            due_policy: value.due_policy.into(),
969            state: value.state.into(),
970            stopped_at: value.stopped_at,
971            created_at: value.created_at,
972            updated_at: value.updated_at,
973        }
974    }
975}
976
977#[derive(Debug, Clone, PartialEq, Eq)]
978pub struct RecurrenceOccurrenceRecord {
979    pub series_id: RecurrenceSeriesId,
980    pub slot_on: String,
981    pub task_id: Option<TaskId>,
982    pub outcome: Option<RecurrenceOutcome>,
983    pub resolved_at: Option<String>,
984    pub projection_state: RecurrenceProjectionState,
985    pub archived_at: Option<String>,
986}
987
988impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
989    fn from(value: RecurrenceOccurrence) -> Self {
990        Self {
991            series_id: value.series_id,
992            slot_on: value.slot_on.to_string(),
993            task_id: value.task_id,
994            outcome: value.outcome.map(Into::into),
995            resolved_at: value.resolved_at,
996            projection_state: value.projection_state.into(),
997            archived_at: value.archived_at,
998        }
999    }
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq)]
1003pub struct RecurrenceCreateResult {
1004    pub series: RecurrenceSeriesRecord,
1005    pub series_ref: String,
1006    pub occurrence: RecurrenceOccurrenceRecord,
1007    pub task: TaskRecord,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011pub struct RecurrenceTemplateUpdateResult {
1012    pub series: RecurrenceSeriesRecord,
1013    pub changed: bool,
1014}
1015
1016#[derive(Debug, Clone, PartialEq, Eq)]
1017pub struct RecurrenceResolveResult {
1018    pub series: RecurrenceSeriesRecord,
1019    pub occurrence: RecurrenceOccurrenceRecord,
1020    pub task: TaskRecord,
1021    pub successor: Option<TaskRecord>,
1022}
1023
1024#[derive(Debug, Clone, PartialEq, Eq)]
1025pub struct RecurrenceStateResult {
1026    pub series: RecurrenceSeriesRecord,
1027    pub occurrence: Option<RecurrenceOccurrenceRecord>,
1028}
1029
1030impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
1031    fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
1032        Self {
1033            series: value.series.into(),
1034            occurrence: value.occurrence.map(Into::into),
1035        }
1036    }
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct RecurrenceRefResolution {
1041    pub series_id: RecurrenceSeriesId,
1042    pub series_ref: String,
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq, Default)]
1046pub struct RecurrenceCounts {
1047    pub completed: u64,
1048    pub skipped: u64,
1049    pub missed: u64,
1050    pub pause_intervals: u64,
1051    pub latest_slot_on: Option<String>,
1052    pub latest_outcome: Option<RecurrenceOutcome>,
1053}
1054
1055impl From<InternalRecurrenceCounts> for RecurrenceCounts {
1056    fn from(value: InternalRecurrenceCounts) -> Self {
1057        Self {
1058            completed: value.completed as u64,
1059            skipped: value.skipped as u64,
1060            missed: value.missed as u64,
1061            pause_intervals: value.pause_intervals as u64,
1062            latest_slot_on: value.latest_slot_on,
1063            latest_outcome: value.latest_outcome.map(Into::into),
1064        }
1065    }
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Eq)]
1069pub struct RecurrenceSeriesSummary {
1070    pub series: RecurrenceSeriesRecord,
1071    pub series_ref: String,
1072    pub rule_label: String,
1073    pub current_slot_on: Option<String>,
1074    pub current_task_ref: Option<String>,
1075    pub counts: RecurrenceCounts,
1076}
1077
1078impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
1079    fn from(value: InternalRecurrenceSeriesSummary) -> Self {
1080        Self {
1081            series: value.series.into(),
1082            series_ref: value.series_ref,
1083            rule_label: value.rule_label,
1084            current_slot_on: value.current_slot_on,
1085            current_task_ref: value.current_task_ref,
1086            counts: value.counts.into(),
1087        }
1088    }
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq)]
1092pub struct RecurrenceSeriesConflict {
1093    pub field: String,
1094    pub variant_a: String,
1095    pub local_value: String,
1096    pub variant_b: String,
1097    pub remote_value: String,
1098}
1099
1100#[derive(Debug, Clone, PartialEq, Eq)]
1101pub struct RecurrenceSeriesDetail {
1102    pub series: RecurrenceSeriesRecord,
1103    pub labels: Vec<String>,
1104    pub summary: RecurrenceSeriesSummary,
1105    pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
1106    pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
1107}
1108
1109impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
1110    fn from(value: InternalRecurrenceSeriesDetail) -> Self {
1111        Self {
1112            series: value.series.into(),
1113            labels: value.labels,
1114            summary: value.summary.into(),
1115            current_occurrence: value.current_occurrence.map(Into::into),
1116            lifecycle_conflicts: value
1117                .lifecycle_conflicts
1118                .into_iter()
1119                .map(|conflict| RecurrenceSeriesConflict {
1120                    field: conflict.field,
1121                    variant_a: conflict.variant_a,
1122                    local_value: conflict.local_value,
1123                    variant_b: conflict.variant_b,
1124                    remote_value: conflict.remote_value,
1125                })
1126                .collect(),
1127        }
1128    }
1129}
1130
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132pub enum RecurrenceHistoryKind {
1133    Completed,
1134    Skipped,
1135    Missed,
1136    Paused,
1137}
1138
1139impl From<InternalRecurrenceHistoryKind> for RecurrenceHistoryKind {
1140    fn from(value: InternalRecurrenceHistoryKind) -> Self {
1141        match value {
1142            InternalRecurrenceHistoryKind::Completed => Self::Completed,
1143            InternalRecurrenceHistoryKind::Skipped => Self::Skipped,
1144            InternalRecurrenceHistoryKind::Missed => Self::Missed,
1145            InternalRecurrenceHistoryKind::Paused => Self::Paused,
1146        }
1147    }
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq)]
1151pub struct RecurrenceHistoryRow {
1152    pub kind: RecurrenceHistoryKind,
1153    pub slot_on: Option<String>,
1154    pub interval_started_at: Option<String>,
1155    pub interval_ended_at: Option<String>,
1156    pub task_id: Option<TaskId>,
1157    pub task_ref: Option<String>,
1158    pub openable: bool,
1159    pub archived_projection: bool,
1160    pub resolved_at: Option<String>,
1161}
1162
1163impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
1164    fn from(value: InternalRecurrenceHistoryEntry) -> Self {
1165        Self {
1166            kind: value.kind.into(),
1167            slot_on: value.slot_on,
1168            interval_started_at: value.interval_started_at,
1169            interval_ended_at: value.interval_ended_at,
1170            task_id: value.task_id,
1171            task_ref: value.task_ref,
1172            openable: value.openable,
1173            archived_projection: value.archived_projection,
1174            resolved_at: value.resolved_at,
1175        }
1176    }
1177}
1178
1179#[derive(Debug, Clone, PartialEq, Eq)]
1180pub struct RecurrenceHistoryPage {
1181    pub series_ref: String,
1182    pub items: Vec<RecurrenceHistoryRow>,
1183    pub offset: usize,
1184    pub limit: usize,
1185    pub total: usize,
1186    pub has_more: bool,
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq)]
1190pub struct TaskRecurrenceSummary {
1191    pub series_id: RecurrenceSeriesId,
1192    pub series_ref: String,
1193    pub slot_on: String,
1194    pub rule_label: String,
1195    pub timezone: String,
1196    pub lifecycle: RecurrenceSeriesState,
1197    pub outcome: Option<RecurrenceOutcome>,
1198    pub projection_state: RecurrenceProjectionState,
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Eq)]
1202pub struct RecurrenceTaskGroup {
1203    pub series_id: RecurrenceSeriesId,
1204    pub series_ref: String,
1205    pub counts: RecurrenceCounts,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1209pub struct TaskSummary {
1210    pub task: TaskRecord,
1211    pub display_ref: String,
1212    pub recurrence: Option<TaskRecurrenceSummary>,
1213    pub recurrence_group: Option<RecurrenceTaskGroup>,
1214}
1215
1216impl From<TaskListItem> for TaskSummary {
1217    fn from(value: TaskListItem) -> Self {
1218        Self {
1219            task: value.task.into(),
1220            display_ref: value.display_ref,
1221            recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
1222                series_id: summary.series_id,
1223                series_ref: summary.series_ref,
1224                slot_on: summary.slot_on,
1225                rule_label: summary.rule_label,
1226                timezone: summary.timezone,
1227                lifecycle: summary.lifecycle.into(),
1228                outcome: summary.outcome.map(Into::into),
1229                projection_state: summary.projection_state.into(),
1230            }),
1231            recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
1232                series_id: group.series_id,
1233                series_ref: group.series_ref,
1234                counts: group.counts.into(),
1235            }),
1236        }
1237    }
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Eq)]
1241pub struct TaskRecord {
1242    pub id: TaskId,
1243    pub workspace_id: WorkspaceId,
1244    pub title: String,
1245    pub description: String,
1246    pub project_id: ProjectId,
1247    pub project_key: String,
1248    pub project_prefix: String,
1249    pub status: TaskStatus,
1250    pub priority: TaskPriority,
1251    pub created_at: String,
1252    pub updated_at: String,
1253    pub available_at: Option<String>,
1254    pub due_on: Option<String>,
1255}
1256
1257impl From<Task> for TaskRecord {
1258    fn from(task: Task) -> Self {
1259        Self {
1260            id: task.id,
1261            workspace_id: task.workspace_id,
1262            title: task.title,
1263            description: task.description,
1264            project_id: task.project_id,
1265            project_key: task.project_key,
1266            project_prefix: task.project_prefix,
1267            status: task.status,
1268            priority: task.priority,
1269            created_at: task.created_at,
1270            updated_at: task.updated_at,
1271            available_at: task.available_at,
1272            due_on: task.due_on,
1273        }
1274    }
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq)]
1278pub struct TaskUpdateResult {
1279    pub task: TaskRecord,
1280    pub changed: bool,
1281}
1282
1283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1284pub enum ConflictField {
1285    Title,
1286    Description,
1287    Project,
1288    Status,
1289    Priority,
1290    AvailableAt,
1291    DueOn,
1292    Deleted,
1293    IsEpic,
1294}
1295
1296impl ConflictField {
1297    pub const fn as_str(self) -> &'static str {
1298        match self {
1299            Self::Title => "title",
1300            Self::Description => "description",
1301            Self::Project => "project",
1302            Self::Status => "status",
1303            Self::Priority => "priority",
1304            Self::AvailableAt => "available_at",
1305            Self::DueOn => "due_on",
1306            Self::Deleted => "deleted",
1307            Self::IsEpic => "is_epic",
1308        }
1309    }
1310
1311    fn from_task_field(field: TaskField) -> Self {
1312        match field {
1313            TaskField::Title => Self::Title,
1314            TaskField::Description => Self::Description,
1315            TaskField::Project => Self::Project,
1316            TaskField::Status => Self::Status,
1317            TaskField::Priority => Self::Priority,
1318            TaskField::AvailableAt => Self::AvailableAt,
1319            TaskField::DueOn => Self::DueOn,
1320            TaskField::Deleted => Self::Deleted,
1321            TaskField::IsEpic => Self::IsEpic,
1322        }
1323    }
1324}
1325
1326#[derive(Debug, Clone, PartialEq, Eq)]
1327pub struct ConflictSummary {
1328    pub task_id: TaskId,
1329    pub task_title: String,
1330    pub project_key: String,
1331    pub project_prefix: String,
1332    pub field: ConflictField,
1333}
1334
1335#[derive(Debug, Clone, PartialEq, Eq)]
1336pub struct Conflict {
1337    pub task_id: TaskId,
1338    pub field: ConflictField,
1339    pub local_value: String,
1340    pub remote_value: String,
1341}
1342
1343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1344pub enum ConflictChoice {
1345    Local,
1346    Remote,
1347}
1348
1349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350pub enum ErrorCode {
1351    Validation,
1352    NotFound,
1353    OpenConflict,
1354    Database,
1355    Internal,
1356}
1357
1358impl ErrorCode {
1359    pub const fn as_str(self) -> &'static str {
1360        match self {
1361            Self::Validation => "validation",
1362            Self::NotFound => "not_found",
1363            Self::OpenConflict => "open_conflict",
1364            Self::Database => "database",
1365            Self::Internal => "internal",
1366        }
1367    }
1368}
1369
1370#[derive(Debug, Clone, PartialEq, Eq)]
1371pub struct Error {
1372    pub code: ErrorCode,
1373    pub message: String,
1374}
1375
1376impl Error {
1377    fn new(code: ErrorCode, message: String) -> Self {
1378        Self { code, message }
1379    }
1380
1381    fn database_open(error: InternalError) -> Self {
1382        Self::new(ErrorCode::Database, error.to_string())
1383    }
1384
1385    fn from_internal(error: InternalError) -> Self {
1386        let message = error.to_string();
1387        let code = if error.chain().any(|cause| {
1388            cause
1389                .downcast_ref::<crate::mutation::OpenConflictError>()
1390                .is_some()
1391        }) || message.contains("recurrence-conflicted-field")
1392        {
1393            ErrorCode::OpenConflict
1394        } else if error.chain().any(|cause| {
1395            cause
1396                .downcast_ref::<crate::operations::ConflictNotFoundError>()
1397                .is_some()
1398        }) || error
1399            .chain()
1400            .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
1401            .any(|error| matches!(error, sqlx::Error::RowNotFound))
1402            || message.contains("recurrence-series-not-found")
1403            || message.contains("recurrence-occurrence-not-found")
1404        {
1405            ErrorCode::NotFound
1406        } else if error
1407            .chain()
1408            .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
1409        {
1410            ErrorCode::Database
1411        } else if message.starts_with("error recurrence-")
1412            || message.starts_with("invalid recurrence")
1413        {
1414            ErrorCode::Validation
1415        } else {
1416            ErrorCode::Internal
1417        };
1418        Self::new(code, message)
1419    }
1420}
1421
1422impl fmt::Display for Error {
1423    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1424        write!(formatter, "{}: {}", self.code.as_str(), self.message)
1425    }
1426}
1427
1428impl std::error::Error for Error {}
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::*;
1433
1434    #[test]
1435    fn error_codes_are_stable_and_internal_errors_are_distinct() {
1436        assert_eq!(ErrorCode::Validation.as_str(), "validation");
1437        assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
1438        assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
1439        assert_eq!(ErrorCode::Database.as_str(), "database");
1440        assert_eq!(ErrorCode::Internal.as_str(), "internal");
1441        assert_eq!(
1442            Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
1443            ErrorCode::Internal
1444        );
1445        assert_eq!(
1446            Error::from_internal(sqlx::Error::PoolClosed.into()).code,
1447            ErrorCode::Database
1448        );
1449    }
1450}