Skip to main content

aven_core/
api.rs

1mod epics;
2pub use epics::{EpicProgress, EpicSummary};
3
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use anyhow::Error as InternalError;
8use chrono::{Days, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
9
10pub use crate::attachments::AttachmentBytesState as AttachmentAvailability;
11use crate::choices::{TaskPriority, TaskSource, TaskStatus};
12use crate::db::Database;
13use crate::ids::{MetadataFieldId, ProjectId, TaskId, WorkspaceId};
14use crate::metadata::{MetadataFieldUsage, TaskMetadataInput, TaskMetadataValue};
15use crate::operations::{
16    ConsumerTaskMutation as InternalConsumerTaskMutation, CreateRecurrenceSeriesParams,
17    RecurrenceSeriesDraft, RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate,
18    TaskCreationOptions, TaskDraft, TaskUpdate as InternalTaskUpdate,
19    UpdateRecurrenceTemplateParams,
20};
21pub use crate::pairing::{PairingInvitation, PairingInvitationError};
22use crate::query::{
23    MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
24    RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
25    RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
26    RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection, TaskFilters,
27    TaskListItem, TaskQueryMode, TaskSearchQuery, TaskSort,
28};
29pub use crate::query::{RecurrenceHistoryKind, SearchMatchedField};
30pub use crate::queue::{QueueBand, QueueDateKind, QueueReason};
31pub use crate::recurrence::{
32    RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
33    RecurrenceSeriesState,
34};
35use crate::recurrence::{
36    RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, TimeZoneId,
37    WeekdaySet,
38};
39use crate::sync::SyncSession;
40use crate::task_fields::TaskField;
41use crate::types::{Project, RecurrenceOccurrence, RecurrenceSeries, Task};
42use crate::undo::TaskUndoSnapshot;
43use crate::workspaces::Workspace;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SyncFacts {
47    pub pending_changes: i64,
48    pub attachment_uploads: i64,
49    pub attachment_downloads: u64,
50    pub metadata_confirmed_at: Option<String>,
51    pub metadata_caught_up: bool,
52    pub compatibility_block: Option<crate::sync::protocol::SyncCompatibilityError>,
53}
54
55#[derive(Clone)]
56pub struct ConnectionInspection {
57    pub server_url: Option<String>,
58    pub server_origin: Option<String>,
59    pub last_success_at: Option<String>,
60    pub facts: SyncFacts,
61}
62
63/// Reads an isolated snapshot without initializing, migrating, or repairing the replica.
64pub async fn inspect_connection(path: impl AsRef<Path>) -> Result<ConnectionInspection, Error> {
65    let database = Database::inspect(path.as_ref())
66        .await
67        .database
68        .ok_or_else(|| {
69            Error::new(
70                ErrorCode::Database,
71                "local connection information is unavailable".to_string(),
72            )
73        })?;
74    let server_url = database
75        .meta("sync_server_url")
76        .await
77        .map_err(Error::from_internal)?;
78    let server_origin = server_url.as_deref().and_then(|server| {
79        let url = url::Url::parse(server).ok()?;
80        crate::sync::wire::sync_server_url_is_valid_url(&url)
81            .then(|| url.origin().ascii_serialization())
82    });
83    let last_success_at = database
84        .meta("sync_last_success_at")
85        .await
86        .map_err(Error::from_internal)?;
87    let facts = database.sync_facts().await.map_err(Error::from_internal)?;
88    Ok(ConnectionInspection {
89        server_url,
90        server_origin,
91        last_success_at,
92        facts,
93    })
94}
95
96#[derive(Clone)]
97pub struct Store {
98    database: Database,
99    #[cfg(test)]
100    fail_queue_reads: std::sync::Arc<std::sync::atomic::AtomicBool>,
101}
102
103impl Store {
104    pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
105        let database = Database::open(path.as_ref())
106            .await
107            .map_err(Error::database_open)?;
108        Ok(Self {
109            database,
110            #[cfg(test)]
111            fail_queue_reads: Default::default(),
112        })
113    }
114
115    pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
116        let root = crate::attachments::default_blob_dir(self.database.path());
117        let objects = root.join("objects").join("sha256");
118        let trash = root.join("trash");
119        let previews = root.join("cache").join("previews");
120        for directory in [&objects, &trash, &previews] {
121            std::fs::create_dir_all(directory)
122                .map_err(|error| Error::from_internal(error.into()))?;
123        }
124        Ok(StorageLayout {
125            root,
126            staging: objects.clone(),
127            objects,
128            trash,
129            previews,
130        })
131    }
132
133    pub async fn start_sync_session(
134        &self,
135        server: String,
136        auth_token: Option<String>,
137        page_budget: Option<usize>,
138    ) -> Result<SyncSession, Error> {
139        if !crate::sync::wire::sync_server_url_is_valid(&server) {
140            return Err(Error::new(
141                ErrorCode::Validation,
142                "invalid sync server URL".to_string(),
143            ));
144        }
145        SyncSession::start(self.database.clone(), server, auth_token, page_budget)
146            .await
147            .map_err(Error::from_internal)
148    }
149
150    pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
151        self.database
152            .list_workspaces()
153            .await
154            .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
155            .map_err(Error::from_internal)
156    }
157
158    pub async fn sync_facts(&self) -> Result<SyncFacts, Error> {
159        self.database
160            .sync_facts()
161            .await
162            .map_err(Error::from_internal)
163    }
164
165    pub async fn queue_state(&self) -> Result<QueueState, Error> {
166        let selected = self
167            .database
168            .restore_queue_workspace()
169            .await
170            .map_err(Error::from_internal)?;
171        self.queue_state_for(selected).await
172    }
173
174    pub async fn select_queue_workspace(
175        &self,
176        workspace_id: &WorkspaceId,
177    ) -> Result<QueueState, Error> {
178        let selected = self
179            .database
180            .select_queue_workspace(workspace_id)
181            .await
182            .map_err(Error::from_internal)?;
183        self.queue_state_for(selected).await
184    }
185
186    async fn queue_state_for(&self, selected: Workspace) -> Result<QueueState, Error> {
187        #[cfg(test)]
188        if self
189            .fail_queue_reads
190            .load(std::sync::atomic::Ordering::SeqCst)
191        {
192            return Err(Error::from_internal(sqlx::Error::PoolClosed.into()));
193        }
194        let workspaces = self
195            .database
196            .list_workspace_open_summaries()
197            .await
198            .map_err(Error::from_internal)?
199            .into_iter()
200            .map(|summary| WorkspaceQueueSummary {
201                workspace: summary.workspace.into(),
202                open_task_count: summary.open_task_count,
203            })
204            .collect();
205        let projects = self
206            .database
207            .list_projects(&selected.id, None)
208            .await
209            .map_err(Error::from_internal)?
210            .into_iter()
211            .map(ProjectRecord::from)
212            .collect();
213        let labels = self
214            .database
215            .list_labels(&selected.id, None)
216            .await
217            .map_err(Error::from_internal)?;
218        let queue = self.queue_report(&selected.id).await?;
219        Ok(QueueState {
220            selected_workspace: selected.into(),
221            workspaces,
222            projects,
223            labels,
224            queue,
225        })
226    }
227
228    pub async fn project_tasks(
229        &self,
230        workspace_id: &WorkspaceId,
231        project_id: &ProjectId,
232    ) -> Result<Vec<TaskListRow>, Error> {
233        self.workspace(workspace_id).await?;
234        let project = self
235            .database
236            .find_project_by_id(workspace_id, project_id)
237            .await
238            .map_err(Error::from_internal)?
239            .ok_or_else(|| Error::new(ErrorCode::NotFound, "project not found".to_string()))?;
240        self.database
241            .list_task_summary_items(
242                workspace_id,
243                TaskFilters {
244                    project: Some(project.key),
245                    ..TaskFilters::default()
246                },
247                TaskQueryMode::Flat,
248                TaskSort::Updated,
249                SortDirection::Desc,
250                None,
251            )
252            .await
253            .map(|items| items.into_iter().map(TaskListRow::from).collect())
254            .map_err(Error::from_internal)
255    }
256
257    pub async fn search_tasks(
258        &self,
259        workspace_id: &WorkspaceId,
260        text: &str,
261    ) -> Result<Vec<TaskSearchResult>, Error> {
262        self.workspace(workspace_id).await?;
263        let text = text.trim();
264        if text.is_empty() {
265            return Ok(Vec::new());
266        }
267        if text.chars().count() > 256 {
268            return Err(Error::new(
269                ErrorCode::Validation,
270                "search query is too long".to_string(),
271            ));
272        }
273        self.database
274            .search_task_items(
275                workspace_id,
276                TaskSearchQuery {
277                    text: text.to_string(),
278                    project: None,
279                    metadata: Vec::new(),
280                    has_metadata: Vec::new(),
281                    missing_metadata: Vec::new(),
282                    include_deleted: false,
283                    limit: 100,
284                },
285            )
286            .await
287            .map(|results| {
288                results
289                    .into_iter()
290                    .map(|result| TaskSearchResult {
291                        task: TaskListRow::from(result.item),
292                        matched_field: result.matched_field,
293                        snippet: result.snippet,
294                    })
295                    .collect()
296            })
297            .map_err(Error::from_internal)
298    }
299
300    pub async fn task_detail(
301        &self,
302        workspace_id: &WorkspaceId,
303        task_id: &TaskId,
304    ) -> Result<TaskDetail, Error> {
305        let workspace = self.workspace(workspace_id).await?;
306        let mut connection = self
307            .database
308            .acquire_reader()
309            .await
310            .map_err(Error::from_internal)?;
311        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
312            .await
313            .map_err(Error::from_internal)?;
314        drop(connection);
315        let detail = self
316            .database
317            .task_detail(&task)
318            .await
319            .map_err(Error::from_internal)?;
320        TaskDetail::from_detail(detail, workspace)
321    }
322
323    /// Returns an owned, bounded snapshot without exposing object storage paths.
324    pub async fn attachment_bytes(
325        &self,
326        workspace_id: &WorkspaceId,
327        task_id: &TaskId,
328        attachment_id: &str,
329    ) -> Result<AttachmentRead, Error> {
330        use crate::attachments::{MAX_BLOB_BYTES, default_blob_dir, object_path, sha256_hex};
331        use std::io::Read;
332
333        crate::attachments::validate_attachment_id(attachment_id).map_err(Error::from_internal)?;
334        let workspace = self.workspace(workspace_id).await?;
335        let lease = match self
336            .database
337            .acquire_live_attachment_read_lease(&workspace, attachment_id)
338            .await
339        {
340            Ok(lease) => lease,
341            Err(error) => {
342                return match error.downcast_ref::<crate::operations::AttachmentReadFailure>() {
343                    Some(crate::operations::AttachmentReadFailure::Invalidated) => {
344                        Ok(AttachmentRead::Invalidated)
345                    }
346                    Some(crate::operations::AttachmentReadFailure::Unavailable) => {
347                        Ok(AttachmentRead::Unavailable)
348                    }
349                    None => Err(Error::from_internal(error)),
350                };
351            }
352        };
353        let result = if lease.task_id != task_id.as_str() {
354            Ok(AttachmentRead::Invalidated)
355        } else {
356            let path = object_path(&default_blob_dir(self.database.path()), &lease.sha256);
357            match path {
358                Err(error) => Err(error),
359                Ok(path) => {
360                    let expected_size = lease.byte_size;
361                    let expected_hash = lease.sha256.clone();
362                    crate::attachments::run_preview(move || {
363                        let file = match std::fs::File::open(path) {
364                            Ok(file) => file,
365                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
366                                return Ok(AttachmentRead::Missing);
367                            }
368                            Err(error) => return Err(error.into()),
369                        };
370                        let mut bytes = Vec::new();
371                        file.take(MAX_BLOB_BYTES as u64 + 1)
372                            .read_to_end(&mut bytes)?;
373                        if bytes.len() > MAX_BLOB_BYTES
374                            || bytes.len() as i64 != expected_size
375                            || sha256_hex(&bytes) != expected_hash
376                        {
377                            return Ok(AttachmentRead::Corrupt);
378                        }
379                        Ok(AttachmentRead::Bytes { bytes })
380                    })
381                    .await
382                }
383            }
384        };
385        self.database
386            .release_attachment_lease(&lease.lease_id)
387            .await
388            .map_err(Error::from_internal)?;
389        result.map_err(Error::from_internal)
390    }
391
392    pub async fn capture_queue_task(
393        &self,
394        workspace_id: &WorkspaceId,
395        input: TaskCapture,
396    ) -> Result<TaskCaptureResult, Error> {
397        let title = input.title.trim();
398        if title.is_empty() {
399            return Err(Error::new(
400                ErrorCode::Validation,
401                "task title is required".to_string(),
402            ));
403        }
404        validate_optional_date("due_on", input.due_on.as_deref())?;
405        let workspace = self.workspace(workspace_id).await?;
406        let project = input
407            .project
408            .ok_or_else(|| Error::new(ErrorCode::Validation, "project is required".to_string()))?;
409        let outcome = self
410            .database
411            .create_task_with_options(
412                &workspace,
413                TaskDraft {
414                    title: title.to_string(),
415                    description: input.description,
416                    project: Some(project),
417                    status: input.status.as_str().to_string(),
418                    priority: input.priority.as_str().to_string(),
419                    source: input.source,
420                    labels: input.labels,
421                    metadata: Vec::new(),
422                    available_at: None,
423                    due_on: input.due_on,
424                    is_epic: false,
425                },
426                TaskCreationOptions::for_consumer_capture(),
427            )
428            .await
429            .map_err(Error::from_internal)?;
430        let expected = outcome.undo_snapshot.ok_or_else(|| {
431            Error::new(
432                ErrorCode::Internal,
433                "capture undo state is unavailable".to_string(),
434            )
435        })?;
436        let undo_token = serde_json::to_string(&CaptureUndoToken {
437            workspace_id: workspace_id.clone(),
438            task_id: outcome.task.id.clone(),
439            expected,
440        })
441        .map_err(|error| Error::from_internal(error.into()))?;
442        let state = self.queue_state_for(workspace).await.ok();
443        let display_ref = state
444            .as_ref()
445            .and_then(|state| {
446                state
447                    .queue
448                    .tasks
449                    .iter()
450                    .find(|task| task.id == outcome.task.id)
451            })
452            .map(|task| task.display_ref.clone())
453            .unwrap_or_else(|| outcome.task.id.to_string());
454        Ok(TaskCaptureResult {
455            task_id: outcome.task.id,
456            display_ref,
457            undo_token,
458            state,
459        })
460    }
461
462    pub async fn undo_queue_capture(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
463        let token: CaptureUndoToken = serde_json::from_str(undo_token).map_err(|_| {
464            Error::new(
465                ErrorCode::Validation,
466                "invalid capture undo token".to_string(),
467            )
468        })?;
469        let workspace = self.workspace(&token.workspace_id).await?;
470        self.database
471            .undo_queue_capture(&workspace, &token.task_id, &token.expected)
472            .await
473            .map_err(Error::from_internal)?;
474        Ok(self.queue_state_for(workspace).await.ok())
475    }
476
477    /// Returns the committed status and its conditional Undo receipt without a read-model refresh.
478    pub async fn update_detail_status(
479        &self,
480        workspace_id: &WorkspaceId,
481        task_id: &TaskId,
482        status: TaskStatus,
483    ) -> Result<DetailStatusReceipt, Error> {
484        let workspace = self.workspace(workspace_id).await?;
485        let mutation = InternalConsumerTaskMutation::DetailStatus {
486            status: status.as_str().to_string(),
487            expected_version: None,
488        };
489        let outcome = self
490            .database
491            .mutate_consumer_task(&workspace, task_id, &mutation)
492            .await
493            .map_err(Error::from_internal)?;
494        let status = TaskStatus::parse(&outcome.after.status)
495            .expect("persisted task status is validated by core mutations");
496        let mutation = InternalConsumerTaskMutation::DetailStatus {
497            status: status.as_str().to_string(),
498            expected_version: outcome.field_version,
499        };
500        let undo_token = outcome.changed.then(|| {
501            serde_json::to_string(&MutationUndoToken {
502                workspace_id: workspace_id.clone(),
503                task_id: task_id.clone(),
504                mutation,
505                before: outcome.before,
506                expected: outcome.after,
507            })
508            .expect("Undo snapshots contain only JSON-serializable values")
509        });
510        Ok(DetailStatusReceipt { status, undo_token })
511    }
512
513    pub async fn undo_detail_status(&self, undo_token: &str) -> Result<(), Error> {
514        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
515            Error::new(
516                ErrorCode::Validation,
517                "invalid detail status undo token".to_string(),
518            )
519        })?;
520        if !matches!(
521            token.mutation,
522            InternalConsumerTaskMutation::DetailStatus { .. }
523        ) {
524            return Err(Error::new(
525                ErrorCode::Validation,
526                "invalid detail status undo token".to_string(),
527            ));
528        }
529        self.undo_consumer_mutation(token).await
530    }
531
532    /// Sets the synchronized soft-deletion field and returns a conditional Undo receipt.
533    pub async fn set_task_deleted(
534        &self,
535        workspace_id: &WorkspaceId,
536        task_id: &TaskId,
537        deleted: bool,
538    ) -> Result<TaskDeletionReceipt, Error> {
539        let workspace = self.workspace(workspace_id).await?;
540        let mutation = InternalConsumerTaskMutation::DeletedState {
541            deleted,
542            expected_version: None,
543        };
544        let outcome = self
545            .database
546            .mutate_consumer_task(&workspace, task_id, &mutation)
547            .await
548            .map_err(Error::from_internal)?;
549        let deleted = outcome.after.deleted;
550        let mutation = InternalConsumerTaskMutation::DeletedState {
551            deleted,
552            expected_version: outcome.field_version,
553        };
554        let undo_token = outcome.changed.then(|| {
555            serde_json::to_string(&MutationUndoToken {
556                workspace_id: workspace_id.clone(),
557                task_id: task_id.clone(),
558                mutation,
559                before: outcome.before,
560                expected: outcome.after,
561            })
562            .expect("Undo snapshots contain only JSON-serializable values")
563        });
564        Ok(TaskDeletionReceipt {
565            deleted,
566            undo_token,
567        })
568    }
569
570    pub async fn undo_task_deletion(&self, undo_token: &str) -> Result<(), Error> {
571        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
572            Error::new(
573                ErrorCode::Validation,
574                "invalid task deletion undo token".to_string(),
575            )
576        })?;
577        if !matches!(
578            token.mutation,
579            InternalConsumerTaskMutation::DeletedState { .. }
580        ) {
581            return Err(Error::new(
582                ErrorCode::Validation,
583                "invalid task deletion undo token".to_string(),
584            ));
585        }
586        self.undo_consumer_mutation(token).await
587    }
588
589    async fn undo_consumer_mutation(&self, token: MutationUndoToken) -> Result<(), Error> {
590        let workspace = self.workspace(&token.workspace_id).await?;
591        self.database
592            .undo_consumer_task_mutation(
593                &workspace,
594                &token.task_id,
595                &token.mutation,
596                &token.before,
597                &token.expected,
598            )
599            .await
600            .map_err(Error::from_internal)?;
601        Ok(())
602    }
603
604    pub async fn mutate_queue_task(
605        &self,
606        workspace_id: &WorkspaceId,
607        task_id: &TaskId,
608        input: QueueMutation,
609    ) -> Result<QueueMutationResult, Error> {
610        let workspace = self.workspace(workspace_id).await?;
611        let mutation = input.into_internal()?;
612        let outcome = self
613            .database
614            .mutate_consumer_task(&workspace, task_id, &mutation)
615            .await
616            .map_err(Error::from_internal)?;
617        if !outcome.changed {
618            return Err(Error::new(
619                ErrorCode::GenerationConflict,
620                "quick action did not change the task".to_string(),
621            ));
622        }
623        let undo_token = serde_json::to_string(&MutationUndoToken {
624            workspace_id: workspace_id.clone(),
625            task_id: task_id.clone(),
626            mutation,
627            before: outcome.before,
628            expected: outcome.after,
629        })
630        .map_err(|error| Error::from_internal(error.into()))?;
631        Ok(QueueMutationResult {
632            task_id: task_id.clone(),
633            undo_token,
634            state: self.queue_state_for(workspace).await.ok(),
635        })
636    }
637
638    pub async fn undo_queue_mutation(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
639        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
640            Error::new(
641                ErrorCode::Validation,
642                "invalid quick action undo token".to_string(),
643            )
644        })?;
645        let workspace = self.workspace(&token.workspace_id).await?;
646        self.database
647            .undo_consumer_task_mutation(
648                &workspace,
649                &token.task_id,
650                &token.mutation,
651                &token.before,
652                &token.expected,
653            )
654            .await
655            .map_err(Error::from_internal)?;
656        Ok(self.queue_state_for(workspace).await.ok())
657    }
658
659    pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
660        self.database
661            .find_workspace(name_or_key)
662            .await
663            .map_err(Error::from_internal)?
664            .map(WorkspaceRecord::from)
665            .ok_or_else(|| {
666                Error::new(
667                    ErrorCode::NotFound,
668                    format!("workspace not found: {name_or_key}"),
669                )
670            })
671    }
672
673    pub async fn list_metadata_fields(
674        &self,
675        workspace_id: &WorkspaceId,
676    ) -> Result<Vec<MetadataFieldRecord>, Error> {
677        self.workspace(workspace_id).await?;
678        self.database
679            .list_metadata_fields(workspace_id)
680            .await
681            .map(|fields| fields.into_iter().map(Into::into).collect())
682            .map_err(Error::from_internal)
683    }
684
685    pub async fn rename_metadata_field(
686        &self,
687        workspace_id: &WorkspaceId,
688        key: &str,
689        new_key: &str,
690    ) -> Result<MetadataFieldRecord, Error> {
691        let workspace = self.workspace(workspace_id).await?;
692        let field = self
693            .database
694            .rename_metadata_field(&workspace, key, new_key)
695            .await
696            .map_err(Error::from_internal)?;
697        let fields = self.list_metadata_fields(workspace_id).await?;
698        fields
699            .into_iter()
700            .find(|candidate| candidate.id == field.id)
701            .ok_or_else(|| Error::new(ErrorCode::NotFound, "metadata field not found".to_string()))
702    }
703
704    pub async fn create_task(
705        &self,
706        workspace_id: &WorkspaceId,
707        input: CreateTask,
708    ) -> Result<TaskRecord, Error> {
709        validate_project(&input.project)?;
710        validate_optional_date("available_at", input.available_at.as_deref())?;
711        validate_optional_date("due_on", input.due_on.as_deref())?;
712        let workspace = self.workspace(workspace_id).await?;
713        let outcome = self
714            .database
715            .create_task(
716                &workspace,
717                TaskDraft {
718                    title: input.title,
719                    description: input.description,
720                    project: Some(input.project),
721                    status: input.status.as_str().to_string(),
722                    priority: input.priority.as_str().to_string(),
723                    source: TaskSource::Api,
724                    labels: Vec::new(),
725                    metadata: input
726                        .metadata
727                        .into_iter()
728                        .map(TaskMetadataInput::from)
729                        .collect(),
730                    available_at: input.available_at,
731                    due_on: input.due_on,
732                    is_epic: false,
733                },
734            )
735            .await
736            .map_err(Error::from_internal)?;
737        let metadata = self
738            .database
739            .task_metadata(workspace_id, &outcome.task.id)
740            .await
741            .map_err(Error::from_internal)?;
742        Ok(TaskRecord::with_metadata(outcome.task, metadata))
743    }
744
745    pub async fn task_edit_context(
746        &self,
747        workspace_id: &WorkspaceId,
748        task_id: &TaskId,
749    ) -> Result<TaskEditContext, Error> {
750        let detail = self.task_detail(workspace_id, task_id).await?;
751        let projects = self
752            .database
753            .list_projects(workspace_id, None)
754            .await
755            .map_err(Error::from_internal)?
756            .into_iter()
757            .map(ProjectRecord::from)
758            .collect();
759        let labels = self
760            .database
761            .list_labels(workspace_id, None)
762            .await
763            .map_err(Error::from_internal)?;
764        Ok(TaskEditContext {
765            detail,
766            projects,
767            labels,
768        })
769    }
770
771    pub async fn edit_task(
772        &self,
773        workspace_id: &WorkspaceId,
774        task_id: &TaskId,
775        input: TaskEdit,
776    ) -> Result<bool, Error> {
777        let title = input.title.map(|title| title.trim().to_string());
778        if let Some(title) = title.as_deref() {
779            if title.is_empty() {
780                return Err(Error::new(
781                    ErrorCode::Validation,
782                    "title must not be empty".into(),
783                ));
784            }
785            if title.chars().any(|character| {
786                matches!(
787                    character,
788                    '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
789                )
790            }) {
791                return Err(Error::new(
792                    ErrorCode::Validation,
793                    "title must be a single line".into(),
794                ));
795            }
796        }
797        validate_date_update("available_at", &input.available_at)?;
798        validate_date_update("due_on", &input.due_on)?;
799        let workspace = self.workspace(workspace_id).await?;
800        self.database
801            .edit_consumer_task(
802                &workspace,
803                task_id,
804                input.project_id.as_ref(),
805                InternalTaskUpdate {
806                    title,
807                    description: input.description,
808                    status: input.status.map(|value| value.as_str().to_string()),
809                    priority: input.priority.map(|value| value.as_str().to_string()),
810                    available_at: input.available_at.into_internal(),
811                    due_on: input.due_on.into_internal(),
812                    add_labels: input.add_labels,
813                    remove_labels: input.remove_labels,
814                    ..InternalTaskUpdate::default()
815                },
816            )
817            .await
818            .map_err(Error::from_internal)
819    }
820
821    pub async fn update_task(
822        &self,
823        workspace_id: &WorkspaceId,
824        task_id: &TaskId,
825        input: UpdateTask,
826    ) -> Result<TaskUpdateResult, Error> {
827        if let Some(project) = input.project.as_deref() {
828            validate_project(project)?;
829        }
830        validate_date_update("available_at", &input.available_at)?;
831        validate_date_update("due_on", &input.due_on)?;
832        let workspace = self.workspace(workspace_id).await?;
833        self.fetch_task(workspace_id, task_id).await?;
834        let outcome = self
835            .database
836            .update_task(
837                &workspace,
838                task_id,
839                InternalTaskUpdate {
840                    title: input.title,
841                    description: input.description,
842                    project: input.project,
843                    status: input.status.map(|status| status.as_str().to_string()),
844                    priority: input.priority.map(|priority| priority.as_str().to_string()),
845                    available_at: input.available_at.into_internal(),
846                    due_on: input.due_on.into_internal(),
847                    set_metadata: input
848                        .set_metadata
849                        .into_iter()
850                        .map(TaskMetadataInput::from)
851                        .collect(),
852                    remove_metadata: input.remove_metadata,
853                    ..InternalTaskUpdate::default()
854                },
855            )
856            .await
857            .map_err(Error::from_internal)?;
858        let metadata = self
859            .database
860            .task_metadata(workspace_id, task_id)
861            .await
862            .map_err(Error::from_internal)?;
863        let related = self
864            .database
865            .task_related_links(workspace_id, task_id)
866            .await
867            .map_err(Error::from_internal)?;
868        Ok(TaskUpdateResult {
869            task: TaskRecord::with_metadata_and_related(outcome.task, metadata, related),
870            changed: outcome.changed,
871        })
872    }
873
874    pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
875        self.workspace(workspace_id).await?;
876        let filters = TaskFilters {
877            exclude_epics: true,
878            ..TaskFilters::default()
879        };
880        self.database
881            .list_base_tasks(workspace_id, filters, TaskSort::Created, SortDirection::Asc)
882            .await
883            .map(|tasks| tasks.into_iter().map(TaskRecord::from).collect())
884            .map_err(Error::from_internal)
885    }
886
887    pub async fn queue_report(&self, workspace_id: &WorkspaceId) -> Result<QueueReport, Error> {
888        self.workspace(workspace_id).await?;
889        let tasks = self
890            .database
891            .list_task_summary_items(
892                workspace_id,
893                TaskFilters {
894                    hide_done: true,
895                    ..TaskFilters::default()
896                },
897                TaskQueryMode::RankedQueue,
898                TaskSort::Created,
899                SortDirection::Asc,
900                None,
901            )
902            .await
903            .map_err(Error::from_internal)?
904            .into_iter()
905            .map(QueueTaskSummary::from)
906            .collect();
907        let unresolved_conflict_count = self
908            .database
909            .unresolved_conflict_count_in_workspace(workspace_id)
910            .await
911            .map_err(Error::from_internal)?
912            .clamp(0, i64::from(u32::MAX)) as u32;
913        let last_success_at = self
914            .database
915            .sync_persistence_status()
916            .await
917            .map_err(Error::from_internal)?
918            .last_success;
919        Ok(QueueReport {
920            tasks,
921            unresolved_conflict_count,
922            last_success_at,
923        })
924    }
925
926    pub async fn fetch_task(
927        &self,
928        workspace_id: &WorkspaceId,
929        task_id: &TaskId,
930    ) -> Result<TaskRecord, Error> {
931        let workspace = self.workspace(workspace_id).await?;
932        let mut connection = self
933            .database
934            .acquire_reader()
935            .await
936            .map_err(Error::from_internal)?;
937        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
938            .await
939            .map_err(Error::from_internal)?;
940        drop(connection);
941        let metadata = self
942            .database
943            .task_metadata(workspace_id, task_id)
944            .await
945            .map_err(Error::from_internal)?;
946        let related = self
947            .database
948            .task_related_links(workspace_id, task_id)
949            .await
950            .map_err(Error::from_internal)?;
951        Ok(TaskRecord::with_metadata_and_related(
952            task, metadata, related,
953        ))
954    }
955
956    pub async fn add_related_task(
957        &self,
958        workspace_id: &WorkspaceId,
959        task_id: &TaskId,
960        related_task_id: &TaskId,
961    ) -> Result<RelatedMutationResult, Error> {
962        let workspace = self.workspace(workspace_id).await?;
963        self.database
964            .add_task_related_link(&workspace, task_id, related_task_id)
965            .await
966            .map(|outcome| RelatedMutationResult {
967                changed: outcome.changed,
968            })
969            .map_err(Error::from_internal)
970    }
971
972    pub async fn remove_related_task(
973        &self,
974        workspace_id: &WorkspaceId,
975        task_id: &TaskId,
976        related_task_id: &TaskId,
977    ) -> Result<RelatedMutationResult, Error> {
978        let workspace = self.workspace(workspace_id).await?;
979        self.database
980            .remove_task_related_link(&workspace, task_id, related_task_id)
981            .await
982            .map(|outcome| RelatedMutationResult {
983                changed: outcome.changed,
984            })
985            .map_err(Error::from_internal)
986    }
987
988    pub async fn create_recurrence_series(
989        &self,
990        workspace_id: &WorkspaceId,
991        input: CreateRecurrenceSeries,
992    ) -> Result<RecurrenceCreateResult, Error> {
993        validate_project(&input.project)?;
994        let workspace = self.workspace(workspace_id).await?;
995        let schedule = input.schedule.into_internal()?;
996        let outcome = self
997            .database
998            .create_recurrence_series(
999                &workspace,
1000                CreateRecurrenceSeriesParams::new(RecurrenceSeriesDraft {
1001                    title: input.title,
1002                    description: input.description,
1003                    project: input.project,
1004                    priority: input.priority.as_str().to_string(),
1005                    initial_status: input.initial_status.as_str().to_string(),
1006                    labels: input.labels,
1007                    metadata: input.metadata.into_iter().map(Into::into).collect(),
1008                    schedule,
1009                }),
1010            )
1011            .await
1012            .map_err(Error::from_internal)?;
1013        let metadata = self
1014            .database
1015            .task_metadata(workspace_id, &outcome.task.id)
1016            .await
1017            .map_err(Error::from_internal)?;
1018        Ok(RecurrenceCreateResult {
1019            series: RecurrenceSeriesRecord::from(outcome.series),
1020            series_ref: outcome.series_ref,
1021            occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
1022            task: TaskRecord::with_metadata(outcome.task, metadata),
1023        })
1024    }
1025
1026    pub async fn update_recurrence_template(
1027        &self,
1028        workspace_id: &WorkspaceId,
1029        series_id: &RecurrenceSeriesId,
1030        input: UpdateRecurrenceTemplate,
1031    ) -> Result<RecurrenceTemplateUpdateResult, Error> {
1032        if let Some(project) = input.project.as_deref() {
1033            validate_project(project)?;
1034        }
1035        let workspace = self.workspace(workspace_id).await?;
1036        self.database
1037            .update_recurrence_template(
1038                &workspace,
1039                series_id,
1040                UpdateRecurrenceTemplateParams::new(InternalRecurrenceTemplateUpdate {
1041                    title: input.title,
1042                    description: input.description,
1043                    project: input.project,
1044                    priority: input.priority.map(|value| value.as_str().to_string()),
1045                    initial_status: input.initial_status.map(|value| value.as_str().to_string()),
1046                    labels: input.labels,
1047                    set_metadata: input.set_metadata.into_iter().map(Into::into).collect(),
1048                    remove_metadata: input.remove_metadata,
1049                    available_local_time: input.available_local_time.into_internal()?,
1050                    due_policy: input.due_policy,
1051                }),
1052            )
1053            .await
1054            .map(|outcome| RecurrenceTemplateUpdateResult {
1055                series: RecurrenceSeriesRecord::from(outcome.series),
1056                changed: outcome.changed,
1057            })
1058            .map_err(Error::from_internal)
1059    }
1060
1061    pub async fn resolve_recurrence_ref(
1062        &self,
1063        workspace_id: &WorkspaceId,
1064        input: &str,
1065    ) -> Result<RecurrenceRefResolution, Error> {
1066        let workspace = self.workspace(workspace_id).await?;
1067        let series = self
1068            .database
1069            .resolve_recurrence_ref(&workspace, input)
1070            .await
1071            .map_err(Error::from_internal)?;
1072        let series_ref = self
1073            .database
1074            .recurrence_series_ref(workspace_id, &series.id)
1075            .await
1076            .map_err(Error::from_internal)?;
1077        Ok(RecurrenceRefResolution {
1078            series_id: series.id,
1079            series_ref,
1080        })
1081    }
1082
1083    pub async fn list_recurrence_series(
1084        &self,
1085        workspace_id: &WorkspaceId,
1086    ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
1087        self.workspace(workspace_id).await?;
1088        self.database
1089            .list_recurrence_series(workspace_id)
1090            .await
1091            .map(|values| values.into_iter().map(Into::into).collect())
1092            .map_err(Error::from_internal)
1093    }
1094
1095    pub async fn show_recurrence_series(
1096        &self,
1097        workspace_id: &WorkspaceId,
1098        input: &str,
1099    ) -> Result<RecurrenceSeriesDetail, Error> {
1100        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
1101        self.database
1102            .recurrence_series_detail(workspace_id, &resolution.series_id)
1103            .await
1104            .map(Into::into)
1105            .map_err(Error::from_internal)
1106    }
1107
1108    pub async fn recurrence_history(
1109        &self,
1110        workspace_id: &WorkspaceId,
1111        input: &str,
1112        offset: usize,
1113        limit: usize,
1114    ) -> Result<RecurrenceHistoryPage, Error> {
1115        if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
1116            return Err(Error::new(
1117                ErrorCode::Validation,
1118                format!(
1119                    "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
1120                ),
1121            ));
1122        }
1123        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
1124        self.database
1125            .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
1126            .await
1127            .map(|page| RecurrenceHistoryPage {
1128                series_ref: page.series_ref,
1129                items: page.items.into_iter().map(Into::into).collect(),
1130                offset: page.offset,
1131                limit: page.limit,
1132                total: page.total,
1133                has_more: page.has_more,
1134            })
1135            .map_err(Error::from_internal)
1136    }
1137
1138    pub async fn resolve_recurrence_occurrence(
1139        &self,
1140        workspace_id: &WorkspaceId,
1141        task_id: &TaskId,
1142        outcome: RecurrenceOutcome,
1143    ) -> Result<RecurrenceResolveResult, Error> {
1144        let workspace = self.workspace(workspace_id).await?;
1145        self.database
1146            .resolve_recurrence_occurrence(&workspace, task_id, outcome)
1147            .await
1148            .map(|value| RecurrenceResolveResult {
1149                series: value.series.into(),
1150                occurrence: value.resolved.into(),
1151                task: value.task.into(),
1152                successor: value.successor.map(Into::into),
1153            })
1154            .map_err(Error::from_internal)
1155    }
1156
1157    pub async fn complete_recurrence_occurrence(
1158        &self,
1159        workspace_id: &WorkspaceId,
1160        task_id: &TaskId,
1161    ) -> Result<RecurrenceResolveResult, Error> {
1162        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
1163            .await
1164    }
1165
1166    pub async fn skip_recurrence_occurrence(
1167        &self,
1168        workspace_id: &WorkspaceId,
1169        task_id: &TaskId,
1170    ) -> Result<RecurrenceResolveResult, Error> {
1171        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
1172            .await
1173    }
1174
1175    pub async fn pause_recurrence_series(
1176        &self,
1177        workspace_id: &WorkspaceId,
1178        series_id: &RecurrenceSeriesId,
1179    ) -> Result<RecurrenceStateResult, Error> {
1180        let workspace = self.workspace(workspace_id).await?;
1181        self.database
1182            .pause_recurrence_series(&workspace, series_id)
1183            .await
1184            .map(Into::into)
1185            .map_err(Error::from_internal)
1186    }
1187
1188    pub async fn resume_recurrence_series(
1189        &self,
1190        workspace_id: &WorkspaceId,
1191        series_id: &RecurrenceSeriesId,
1192    ) -> Result<RecurrenceStateResult, Error> {
1193        let workspace = self.workspace(workspace_id).await?;
1194        let at = crate::ids::now_utc();
1195        self.database
1196            .resume_recurrence_series(&workspace, series_id, at)
1197            .await
1198            .map(Into::into)
1199            .map_err(Error::from_internal)
1200    }
1201
1202    pub async fn stop_recurrence_series(
1203        &self,
1204        workspace_id: &WorkspaceId,
1205        series_id: &RecurrenceSeriesId,
1206        skip_current: bool,
1207    ) -> Result<RecurrenceStateResult, Error> {
1208        let workspace = self.workspace(workspace_id).await?;
1209        self.database
1210            .stop_recurrence_series(&workspace, series_id, skip_current)
1211            .await
1212            .map(Into::into)
1213            .map_err(Error::from_internal)
1214    }
1215
1216    pub async fn recurrence_task_report(
1217        &self,
1218        workspace_id: &WorkspaceId,
1219        expand_recurring: bool,
1220    ) -> Result<Vec<TaskSummary>, Error> {
1221        self.workspace(workspace_id).await?;
1222        self.database
1223            .list_task_items(
1224                workspace_id,
1225                TaskFilters {
1226                    exclude_epics: true,
1227                    expand_recurring,
1228                    ..TaskFilters::default()
1229                },
1230                TaskQueryMode::Flat,
1231                TaskSort::Created,
1232                SortDirection::Asc,
1233            )
1234            .await
1235            .map(|items| items.into_iter().map(TaskSummary::from).collect())
1236            .map_err(Error::from_internal)
1237    }
1238
1239    pub async fn list_conflicts(
1240        &self,
1241        workspace_id: &WorkspaceId,
1242    ) -> Result<Vec<ConflictSummary>, Error> {
1243        let workspace = self.workspace(workspace_id).await?;
1244        self.database
1245            .list_conflicts(&workspace, None, None)
1246            .await
1247            .map_err(Error::from_internal)?
1248            .into_iter()
1249            .filter(|conflict| !conflict.field.starts_with("metadata:"))
1250            .map(|conflict| {
1251                Ok(ConflictSummary {
1252                    task_id: conflict.task_id,
1253                    task_title: conflict.title,
1254                    project_key: conflict.project_key,
1255                    project_prefix: conflict.project_prefix,
1256                    field: ConflictField::from_task_field(TaskField::parse_or_unknown(
1257                        &conflict.field,
1258                    )?),
1259                })
1260            })
1261            .collect::<Result<Vec<_>, InternalError>>()
1262            .map_err(Error::from_internal)
1263    }
1264
1265    pub async fn inspect_conflicts(
1266        &self,
1267        workspace_id: &WorkspaceId,
1268        task_id: &TaskId,
1269    ) -> Result<Vec<Conflict>, Error> {
1270        let workspace = self.workspace(workspace_id).await?;
1271        let details = self
1272            .database
1273            .task_conflicts(&workspace, task_id, None)
1274            .await
1275            .map_err(Error::from_internal)?;
1276        let mut connection = self
1277            .database
1278            .acquire_reader()
1279            .await
1280            .map_err(Error::from_internal)?;
1281        let mut conflicts = Vec::with_capacity(details.len());
1282        for detail in details {
1283            if detail.field.starts_with("metadata:") {
1284                continue;
1285            }
1286            let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
1287            let local_value = crate::query::conflict_display_value(
1288                &mut connection,
1289                workspace_id,
1290                field.as_str(),
1291                &detail.local_value,
1292            )
1293            .await
1294            .map_err(Error::from_internal)?;
1295            let remote_value = crate::query::conflict_display_value(
1296                &mut connection,
1297                workspace_id,
1298                field.as_str(),
1299                &detail.remote_value,
1300            )
1301            .await
1302            .map_err(Error::from_internal)?;
1303            conflicts.push(Conflict {
1304                task_id: task_id.clone(),
1305                field: ConflictField::from_task_field(field),
1306                local_value: bounded_conflict_display_value(local_value),
1307                remote_value: bounded_conflict_display_value(remote_value),
1308                variant_a: detail.variant_a,
1309                variant_b: detail.variant_b,
1310            });
1311        }
1312        Ok(conflicts)
1313    }
1314
1315    pub async fn resolve_conflict(
1316        &self,
1317        workspace_id: &WorkspaceId,
1318        task_id: &TaskId,
1319        field: ConflictField,
1320        variant_a: String,
1321        variant_b: String,
1322        resolution: ConflictResolution,
1323    ) -> Result<TaskRecord, Error> {
1324        let workspace = self.workspace(workspace_id).await?;
1325        let field_name = field.as_str();
1326        let resolution = match &resolution {
1327            ConflictResolution::Local => crate::operations::ConflictResolutionValue::Local,
1328            ConflictResolution::Remote => crate::operations::ConflictResolutionValue::Remote,
1329            ConflictResolution::Explicit(value) => {
1330                crate::operations::ConflictResolutionValue::Explicit(value)
1331            }
1332        };
1333        let mut connection = self
1334            .database
1335            .acquire_writer()
1336            .await
1337            .map_err(Error::from_internal)?;
1338        crate::operations::resolve_conflict_transaction(
1339            &mut connection,
1340            &workspace,
1341            task_id,
1342            field_name,
1343            crate::operations::ExpectedConflictIdentity {
1344                variant_a: &variant_a,
1345                variant_b: &variant_b,
1346            },
1347            resolution,
1348        )
1349        .await
1350        .map(|outcome| TaskRecord::from(outcome.task))
1351        .map_err(Error::from_internal)
1352    }
1353
1354    async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
1355        self.database
1356            .workspace_for_id(workspace_id)
1357            .await
1358            .map_err(|error| {
1359                let error = Error::from_internal(error);
1360                if error.code == ErrorCode::NotFound {
1361                    Error::new(
1362                        ErrorCode::NotFound,
1363                        format!("workspace not found: {workspace_id}"),
1364                    )
1365                } else {
1366                    error
1367                }
1368            })
1369    }
1370}
1371
1372fn validate_project(project: &str) -> Result<(), Error> {
1373    if crate::projects::normalize_key(project).is_empty() {
1374        return Err(Error::new(
1375            ErrorCode::Validation,
1376            "project must contain at least one letter or number".to_string(),
1377        ));
1378    }
1379    Ok(())
1380}
1381
1382fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
1383    let Some(value) = value else {
1384        return Ok(());
1385    };
1386    if value.is_empty() {
1387        return Err(Error::new(
1388            ErrorCode::Validation,
1389            format!("{field} must be absent or contain a date"),
1390        ));
1391    }
1392    let result = match field {
1393        "available_at" => crate::time_validation::validate_available_at_value(value),
1394        "due_on" => crate::time_validation::validate_due_on_value(value),
1395        _ => unreachable!("consumer API validates only task dates"),
1396    };
1397    result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
1398}
1399
1400fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
1401    match update {
1402        OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
1403        OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
1404    }
1405}
1406
1407fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
1408    NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
1409        Error::new(
1410            ErrorCode::Validation,
1411            format!("{field} must use YYYY-MM-DD"),
1412        )
1413    })
1414}
1415
1416fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
1417    NaiveTime::parse_from_str(value, "%H:%M")
1418        .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
1419        .map_err(|_| {
1420            Error::new(
1421                ErrorCode::Validation,
1422                format!("{field} must use HH:MM or HH:MM:SS"),
1423            )
1424        })
1425}
1426
1427#[derive(Debug, Clone, PartialEq, Eq)]
1428pub struct StorageLayout {
1429    pub root: PathBuf,
1430    pub objects: PathBuf,
1431    pub staging: PathBuf,
1432    pub trash: PathBuf,
1433    pub previews: PathBuf,
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq)]
1437pub struct WorkspaceRecord {
1438    pub id: WorkspaceId,
1439    pub key: String,
1440    pub name: String,
1441}
1442
1443impl From<Workspace> for WorkspaceRecord {
1444    fn from(workspace: Workspace) -> Self {
1445        Self {
1446            id: workspace.id,
1447            key: workspace.key,
1448            name: workspace.name,
1449        }
1450    }
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq)]
1454pub struct WorkspaceQueueSummary {
1455    pub workspace: WorkspaceRecord,
1456    pub open_task_count: u32,
1457}
1458
1459#[derive(Debug, Clone, PartialEq, Eq)]
1460pub struct ProjectRecord {
1461    pub id: ProjectId,
1462    pub key: String,
1463    pub name: String,
1464}
1465
1466impl From<Project> for ProjectRecord {
1467    fn from(project: Project) -> Self {
1468        Self {
1469            id: project.id,
1470            key: project.key,
1471            name: project.name,
1472        }
1473    }
1474}
1475
1476#[derive(Debug, Clone, PartialEq, Eq)]
1477pub struct QueueState {
1478    pub selected_workspace: WorkspaceRecord,
1479    pub workspaces: Vec<WorkspaceQueueSummary>,
1480    pub projects: Vec<ProjectRecord>,
1481    pub labels: Vec<String>,
1482    pub queue: QueueReport,
1483}
1484
1485#[derive(Debug, Clone, PartialEq, Eq)]
1486pub struct TaskListRow {
1487    pub id: TaskId,
1488    pub title: String,
1489    pub display_ref: String,
1490    pub project_key: String,
1491    pub status: TaskStatus,
1492    pub priority: TaskPriority,
1493    pub due_on: Option<String>,
1494    pub is_epic: bool,
1495}
1496
1497impl From<TaskListItem> for TaskListRow {
1498    fn from(item: TaskListItem) -> Self {
1499        Self {
1500            id: item.task.id,
1501            title: item.task.title,
1502            display_ref: item.display_ref,
1503            project_key: item.task.project_key,
1504            status: item.task.status,
1505            priority: item.task.priority,
1506            due_on: item.task.due_on,
1507            is_epic: item.task.is_epic,
1508        }
1509    }
1510}
1511
1512#[derive(Debug, Clone, PartialEq, Eq)]
1513pub struct TaskSearchResult {
1514    pub task: TaskListRow,
1515    pub matched_field: SearchMatchedField,
1516    pub snippet: Option<String>,
1517}
1518
1519pub use crate::query::TaskNote;
1520
1521#[derive(Debug, Clone, PartialEq, Eq)]
1522pub struct TaskLink {
1523    pub task_id: TaskId,
1524    pub display_ref: String,
1525    pub title: String,
1526    pub project_key: Option<String>,
1527    pub status: TaskStatus,
1528    pub deleted: bool,
1529    pub unresolved: bool,
1530}
1531
1532#[derive(Debug, Clone, PartialEq, Eq)]
1533pub enum AttachmentRead {
1534    Bytes { bytes: Vec<u8> },
1535    Unavailable,
1536    Missing,
1537    Corrupt,
1538    Invalidated,
1539}
1540
1541#[derive(Debug, Clone, PartialEq, Eq)]
1542pub struct TaskAttachment {
1543    pub attachment_id: String,
1544    pub media_type: String,
1545    pub byte_size: i64,
1546    pub filename: Option<String>,
1547    pub alt_text: Option<String>,
1548    pub width: Option<i64>,
1549    pub height: Option<i64>,
1550    pub availability: AttachmentAvailability,
1551    pub has_blob: bool,
1552}
1553
1554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1555pub enum TaskActivityKind {
1556    Created,
1557    Title,
1558    Description,
1559    Status,
1560    Priority,
1561    Project,
1562    Deletion,
1563    Availability,
1564    DueDate,
1565    Epic,
1566    Attachment,
1567    Metadata,
1568    Label,
1569    Note,
1570    Blocker,
1571    Related,
1572    Conflict,
1573    Other,
1574}
1575
1576#[derive(Debug, Clone, PartialEq, Eq)]
1577pub struct TaskActivity {
1578    pub change_id: String,
1579    pub created_at: String,
1580    pub kind: TaskActivityKind,
1581    pub summary: String,
1582    pub anchors_queue_idle: bool,
1583}
1584
1585#[derive(Debug, Clone, PartialEq, Eq)]
1586pub struct TaskDetail {
1587    pub id: TaskId,
1588    pub workspace_id: WorkspaceId,
1589    pub workspace_key: String,
1590    pub workspace_name: String,
1591    pub title: String,
1592    pub description: String,
1593    pub display_ref: String,
1594    pub project_key: String,
1595    pub project_name: String,
1596    pub status: TaskStatus,
1597    pub priority: TaskPriority,
1598    pub created_at: String,
1599    pub updated_at: String,
1600    pub available_at: Option<String>,
1601    pub due_on: Option<String>,
1602    pub deleted: bool,
1603    pub is_epic: bool,
1604    pub labels: Vec<String>,
1605    pub notes: Vec<TaskNote>,
1606    pub activity: Vec<TaskActivity>,
1607    pub blocked_by: Vec<TaskLink>,
1608    pub blocks: Vec<TaskLink>,
1609    pub related: Vec<TaskLink>,
1610    pub epic_parent: Option<TaskLink>,
1611    pub epic_children: Vec<TaskLink>,
1612    pub epic_done_count: u32,
1613    pub epic_total_count: u32,
1614    pub epic_progress: Option<EpicProgress>,
1615    pub attachments: Vec<TaskAttachment>,
1616    pub unresolved_conflict_count: u32,
1617    pub unresolved_conflict_fields: Vec<ConflictField>,
1618}
1619
1620impl TaskDetail {
1621    fn from_detail(detail: crate::query::TaskDetail, workspace: Workspace) -> Result<Self, Error> {
1622        let item = detail.item;
1623        let anchor = item.queue_idle_activity_index();
1624        let activity = item
1625            .activity
1626            .iter()
1627            .enumerate()
1628            .map(|(index, action)| TaskActivity {
1629                change_id: action.change_id.clone(),
1630                created_at: action.created_at.clone(),
1631                kind: match action.verb.as_str() {
1632                    "create" => TaskActivityKind::Created,
1633                    "title" => TaskActivityKind::Title,
1634                    "details" => TaskActivityKind::Description,
1635                    "status" => TaskActivityKind::Status,
1636                    "priority" => TaskActivityKind::Priority,
1637                    "project" => TaskActivityKind::Project,
1638                    "delete" => TaskActivityKind::Deletion,
1639                    "availability" => TaskActivityKind::Availability,
1640                    "due date" => TaskActivityKind::DueDate,
1641                    "epic" => TaskActivityKind::Epic,
1642                    "attachment" => TaskActivityKind::Attachment,
1643                    "metadata" => TaskActivityKind::Metadata,
1644                    "label" => TaskActivityKind::Label,
1645                    "note" => TaskActivityKind::Note,
1646                    "blocker" => TaskActivityKind::Blocker,
1647                    "related link" => TaskActivityKind::Related,
1648                    "conflict" => TaskActivityKind::Conflict,
1649                    _ => TaskActivityKind::Other,
1650                },
1651                summary: action.task_activity_summary(&item.task.title),
1652                anchors_queue_idle: anchor == Some(index),
1653            })
1654            .collect();
1655        let task = item.task;
1656        let blocked_by = detail
1657            .dependencies
1658            .depends_on
1659            .into_iter()
1660            .map(task_link_from_dependency)
1661            .collect();
1662        let blocks = detail
1663            .dependencies
1664            .blocks
1665            .into_iter()
1666            .map(task_link_from_dependency)
1667            .collect();
1668        let related = detail
1669            .related
1670            .into_iter()
1671            .map(|link| TaskLink {
1672                task_id: link.task_id,
1673                display_ref: link.display_ref,
1674                title: link.title,
1675                project_key: None,
1676                status: link.status,
1677                deleted: link.deleted,
1678                unresolved: false,
1679            })
1680            .collect();
1681        let epic_parent = item.epic_parent.map(task_link_from_enriched).transpose()?;
1682        let epic_children = item
1683            .epic_children
1684            .into_iter()
1685            .map(task_link_from_enriched)
1686            .collect::<Result<Vec<_>, _>>()?;
1687        let epic_progress = item.epic_rollup.clone().map(EpicProgress::from);
1688        let (epic_done_count, epic_total_count) = item
1689            .epic_rollup
1690            .map(|rollup| {
1691                (
1692                    rollup.done.min(u32::MAX as usize) as u32,
1693                    rollup.total.min(u32::MAX as usize) as u32,
1694                )
1695            })
1696            .unwrap_or_default();
1697        let attachments = item
1698            .attachments
1699            .into_iter()
1700            .map(|attachment| TaskAttachment {
1701                attachment_id: attachment.attachment_id,
1702                media_type: attachment.media_type,
1703                byte_size: attachment.byte_size,
1704                filename: attachment.filename,
1705                alt_text: attachment.alt_text,
1706                width: attachment.width,
1707                height: attachment.height,
1708                availability: attachment.bytes_state,
1709                has_blob: attachment.has_blob,
1710            })
1711            .collect();
1712        Ok(Self {
1713            id: task.id,
1714            workspace_id: task.workspace_id,
1715            workspace_key: workspace.key,
1716            workspace_name: workspace.name,
1717            title: task.title,
1718            description: task.description,
1719            display_ref: item.display_ref,
1720            project_key: task.project_key,
1721            project_name: detail.project_name,
1722            status: task.status,
1723            priority: task.priority,
1724            created_at: task.created_at,
1725            updated_at: task.updated_at,
1726            available_at: task.available_at,
1727            due_on: task.due_on,
1728            deleted: task.deleted,
1729            is_epic: task.is_epic,
1730            labels: item.labels,
1731            notes: detail.notes,
1732            activity,
1733            blocked_by,
1734            blocks,
1735            related,
1736            epic_parent,
1737            epic_children,
1738            epic_done_count,
1739            epic_total_count,
1740            epic_progress,
1741            attachments,
1742            unresolved_conflict_count: detail.conflicts.len().min(u32::MAX as usize) as u32,
1743            unresolved_conflict_fields: detail
1744                .conflicts
1745                .into_iter()
1746                .filter(|conflict| !conflict.field.starts_with("metadata:"))
1747                .map(|conflict| {
1748                    TaskField::parse_or_unknown(&conflict.field)
1749                        .map(ConflictField::from_task_field)
1750                        .map_err(Error::from_internal)
1751                })
1752                .collect::<Result<Vec<_>, _>>()?,
1753        })
1754    }
1755}
1756
1757fn task_link_from_dependency(link: crate::query::TaskDependencyItem) -> TaskLink {
1758    TaskLink {
1759        task_id: link.task.id,
1760        display_ref: link.display_ref,
1761        title: link.task.title,
1762        project_key: Some(link.task.project_key),
1763        status: link.task.status,
1764        deleted: link.task.deleted,
1765        unresolved: link.unresolved,
1766    }
1767}
1768
1769fn task_link_from_enriched(link: crate::query::TaskDependencyLink) -> Result<TaskLink, Error> {
1770    let status =
1771        TaskStatus::parse(&link.status).map_err(|error| Error::from_internal(error.into()))?;
1772    Ok(TaskLink {
1773        task_id: link.task_id,
1774        display_ref: link.display_ref,
1775        title: link.title,
1776        project_key: None,
1777        status,
1778        deleted: false,
1779        unresolved: link.unresolved,
1780    })
1781}
1782
1783#[derive(Debug, Clone, PartialEq, Eq)]
1784pub struct TaskCapture {
1785    pub title: String,
1786    pub description: String,
1787    pub project: Option<String>,
1788    pub status: TaskStatus,
1789    pub priority: TaskPriority,
1790    pub source: TaskSource,
1791    pub due_on: Option<String>,
1792    pub labels: Vec<String>,
1793}
1794
1795#[derive(Debug, Clone, PartialEq, Eq)]
1796pub struct TaskCaptureResult {
1797    pub task_id: TaskId,
1798    pub display_ref: String,
1799    pub undo_token: String,
1800    /// Optional refreshed projection. Absence does not change the committed receipt.
1801    pub state: Option<QueueState>,
1802}
1803
1804#[derive(serde::Serialize, serde::Deserialize)]
1805struct CaptureUndoToken {
1806    workspace_id: WorkspaceId,
1807    task_id: TaskId,
1808    expected: TaskUndoSnapshot,
1809}
1810
1811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1812pub enum QueueMutationKind {
1813    Start,
1814    Done,
1815    Snooze,
1816    SetPriority,
1817}
1818
1819#[derive(Debug, Clone, PartialEq, Eq)]
1820pub struct QueueMutation {
1821    pub kind: QueueMutationKind,
1822    pub priority: Option<TaskPriority>,
1823    pub local_date: Option<String>,
1824    pub time_zone: Option<String>,
1825}
1826
1827impl QueueMutation {
1828    fn into_internal(self) -> Result<InternalConsumerTaskMutation, Error> {
1829        match self.kind {
1830            QueueMutationKind::Start => Ok(InternalConsumerTaskMutation::Start),
1831            QueueMutationKind::Done => Ok(InternalConsumerTaskMutation::Done),
1832            QueueMutationKind::SetPriority => self
1833                .priority
1834                .map(|priority| InternalConsumerTaskMutation::SetPriority {
1835                    priority: priority.as_str().to_string(),
1836                })
1837                .ok_or_else(|| {
1838                    Error::new(
1839                        ErrorCode::Validation,
1840                        "priority is required for this quick action".to_string(),
1841                    )
1842                }),
1843            QueueMutationKind::Snooze => {
1844                let local_date = self.local_date.ok_or_else(|| {
1845                    Error::new(
1846                        ErrorCode::Validation,
1847                        "local date is required for snooze".to_string(),
1848                    )
1849                })?;
1850                let time_zone = self.time_zone.ok_or_else(|| {
1851                    Error::new(
1852                        ErrorCode::Validation,
1853                        "time zone is required for snooze".to_string(),
1854                    )
1855                })?;
1856                Ok(InternalConsumerTaskMutation::Snooze {
1857                    available_at: tomorrow_start(&local_date, &time_zone)?,
1858                })
1859            }
1860        }
1861    }
1862}
1863
1864#[derive(Debug, Clone, PartialEq, Eq)]
1865pub struct QueueMutationResult {
1866    pub task_id: TaskId,
1867    pub undo_token: String,
1868    /// Optional refreshed projection. Absence does not change the committed receipt.
1869    pub state: Option<QueueState>,
1870}
1871
1872#[derive(Debug, Clone, PartialEq, Eq)]
1873pub struct DetailStatusReceipt {
1874    pub status: TaskStatus,
1875    pub undo_token: Option<String>,
1876}
1877
1878#[derive(Debug, Clone, PartialEq, Eq)]
1879pub struct TaskDeletionReceipt {
1880    pub deleted: bool,
1881    pub undo_token: Option<String>,
1882}
1883
1884#[derive(serde::Serialize, serde::Deserialize)]
1885struct MutationUndoToken {
1886    workspace_id: WorkspaceId,
1887    task_id: TaskId,
1888    mutation: InternalConsumerTaskMutation,
1889    before: TaskUndoSnapshot,
1890    expected: TaskUndoSnapshot,
1891}
1892
1893fn tomorrow_start(local_date: &str, time_zone: &str) -> Result<String, Error> {
1894    let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d")
1895        .map_err(|_| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?
1896        .checked_add_days(Days::new(1))
1897        .ok_or_else(|| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?;
1898    let zone = time_zone
1899        .parse::<chrono_tz::Tz>()
1900        .map_err(|_| Error::new(ErrorCode::Validation, "invalid IANA time zone".to_string()))?;
1901    let mut local = date.and_time(NaiveTime::MIN);
1902    let resolved = loop {
1903        match zone.from_local_datetime(&local) {
1904            LocalResult::Single(value) => break value,
1905            LocalResult::Ambiguous(first, second) => break first.min(second),
1906            LocalResult::None => {
1907                local = local
1908                    .checked_add_signed(chrono::Duration::minutes(1))
1909                    .ok_or_else(|| {
1910                        Error::new(ErrorCode::Validation, "invalid local date".to_string())
1911                    })?;
1912                if local.date() != date {
1913                    return Err(Error::new(
1914                        ErrorCode::Validation,
1915                        "time zone has no valid instant on the next local day".to_string(),
1916                    ));
1917                }
1918            }
1919        }
1920    };
1921    Ok(resolved
1922        .with_timezone(&Utc)
1923        .format("%Y-%m-%dT%H:%M:%SZ")
1924        .to_string())
1925}
1926
1927#[derive(Debug, Clone, PartialEq, Eq)]
1928pub struct MetadataFieldRecord {
1929    pub id: MetadataFieldId,
1930    pub workspace_id: WorkspaceId,
1931    pub key: String,
1932    pub task_count: usize,
1933    pub series_count: usize,
1934}
1935
1936impl From<MetadataFieldUsage> for MetadataFieldRecord {
1937    fn from(value: MetadataFieldUsage) -> Self {
1938        Self {
1939            id: value.field.id,
1940            workspace_id: value.field.workspace_id,
1941            key: value.field.key,
1942            task_count: value.task_count,
1943            series_count: value.series_count,
1944        }
1945    }
1946}
1947
1948#[derive(Debug, Clone, PartialEq, Eq)]
1949pub struct MetadataValueRecord {
1950    pub field_id: MetadataFieldId,
1951    pub key: String,
1952    pub value: String,
1953}
1954
1955impl From<TaskMetadataValue> for MetadataValueRecord {
1956    fn from(value: TaskMetadataValue) -> Self {
1957        Self {
1958            field_id: value.field_id,
1959            key: value.key,
1960            value: value.value,
1961        }
1962    }
1963}
1964
1965#[derive(Debug, Clone, PartialEq, Eq)]
1966pub struct MetadataInput {
1967    pub key: String,
1968    pub value: String,
1969}
1970
1971impl From<MetadataInput> for TaskMetadataInput {
1972    fn from(input: MetadataInput) -> Self {
1973        Self {
1974            expected_field_id: None,
1975            key: input.key,
1976            value: input.value,
1977        }
1978    }
1979}
1980
1981#[derive(Debug, Clone, PartialEq, Eq)]
1982pub struct CreateTask {
1983    pub title: String,
1984    pub description: String,
1985    pub project: String,
1986    pub status: TaskStatus,
1987    pub priority: TaskPriority,
1988    pub metadata: Vec<MetadataInput>,
1989    pub available_at: Option<String>,
1990    pub due_on: Option<String>,
1991}
1992
1993#[derive(Debug, Clone, PartialEq, Eq)]
1994pub struct TaskEditContext {
1995    pub detail: TaskDetail,
1996    pub projects: Vec<ProjectRecord>,
1997    pub labels: Vec<String>,
1998}
1999
2000#[derive(Debug, Clone, PartialEq, Eq, Default)]
2001pub struct TaskEdit {
2002    pub title: Option<String>,
2003    pub description: Option<String>,
2004    pub project_id: Option<ProjectId>,
2005    pub status: Option<TaskStatus>,
2006    pub priority: Option<TaskPriority>,
2007    pub add_labels: Vec<String>,
2008    pub remove_labels: Vec<String>,
2009    pub available_at: OptionalDateUpdate,
2010    pub due_on: OptionalDateUpdate,
2011}
2012
2013#[derive(Debug, Clone, PartialEq, Eq, Default)]
2014pub struct UpdateTask {
2015    pub title: Option<String>,
2016    pub description: Option<String>,
2017    pub project: Option<String>,
2018    pub status: Option<TaskStatus>,
2019    pub priority: Option<TaskPriority>,
2020    pub set_metadata: Vec<MetadataInput>,
2021    pub remove_metadata: Vec<String>,
2022    pub available_at: OptionalDateUpdate,
2023    pub due_on: OptionalDateUpdate,
2024}
2025
2026#[derive(Debug, Clone, PartialEq, Eq, Default)]
2027pub enum OptionalDateUpdate {
2028    #[default]
2029    Unchanged,
2030    Set(String),
2031    Clear,
2032}
2033
2034impl OptionalDateUpdate {
2035    fn into_internal(self) -> Option<Option<String>> {
2036        match self {
2037            Self::Unchanged => None,
2038            Self::Set(value) => Some(Some(value)),
2039            Self::Clear => Some(None),
2040        }
2041    }
2042}
2043
2044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2045pub enum RecurrenceWeekday {
2046    Monday,
2047    Tuesday,
2048    Wednesday,
2049    Thursday,
2050    Friday,
2051    Saturday,
2052    Sunday,
2053}
2054
2055impl From<RecurrenceWeekday> for Weekday {
2056    fn from(value: RecurrenceWeekday) -> Self {
2057        match value {
2058            RecurrenceWeekday::Monday => Self::Mon,
2059            RecurrenceWeekday::Tuesday => Self::Tue,
2060            RecurrenceWeekday::Wednesday => Self::Wed,
2061            RecurrenceWeekday::Thursday => Self::Thu,
2062            RecurrenceWeekday::Friday => Self::Fri,
2063            RecurrenceWeekday::Saturday => Self::Sat,
2064            RecurrenceWeekday::Sunday => Self::Sun,
2065        }
2066    }
2067}
2068
2069impl From<Weekday> for RecurrenceWeekday {
2070    fn from(value: Weekday) -> Self {
2071        match value {
2072            Weekday::Mon => Self::Monday,
2073            Weekday::Tue => Self::Tuesday,
2074            Weekday::Wed => Self::Wednesday,
2075            Weekday::Thu => Self::Thursday,
2076            Weekday::Fri => Self::Friday,
2077            Weekday::Sat => Self::Saturday,
2078            Weekday::Sun => Self::Sunday,
2079        }
2080    }
2081}
2082
2083#[derive(Debug, Clone, PartialEq, Eq)]
2084pub struct RecurrenceRule {
2085    pub frequency: RecurrenceFrequency,
2086    pub interval: u32,
2087    pub weekdays: Vec<RecurrenceWeekday>,
2088}
2089
2090impl RecurrenceRule {
2091    fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
2092        InternalRecurrenceRule::new(
2093            self.frequency,
2094            self.interval,
2095            WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
2096        )
2097        .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
2098    }
2099}
2100
2101impl From<InternalRecurrenceRule> for RecurrenceRule {
2102    fn from(value: InternalRecurrenceRule) -> Self {
2103        Self {
2104            frequency: value.frequency(),
2105            interval: value.interval(),
2106            weekdays: value.weekdays_set().iter().map(Into::into).collect(),
2107        }
2108    }
2109}
2110
2111#[derive(Debug, Clone, PartialEq, Eq)]
2112pub struct RecurrenceScheduleInput {
2113    pub rule: RecurrenceRule,
2114    pub timezone: String,
2115    pub start_on: String,
2116    pub available_local_time: Option<String>,
2117    pub due_policy: RecurrenceDuePolicy,
2118}
2119
2120impl RecurrenceScheduleInput {
2121    fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
2122        let timezone = self
2123            .timezone
2124            .parse::<TimeZoneId>()
2125            .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
2126        let start_on = parse_date("start_on", &self.start_on)?;
2127        let available_local_time = self
2128            .available_local_time
2129            .map(|value| parse_local_time("available_local_time", &value))
2130            .transpose()?;
2131        Ok(RecurrenceSchedule::new(
2132            self.rule.into_internal()?,
2133            timezone,
2134            start_on,
2135            available_local_time,
2136            self.due_policy,
2137        ))
2138    }
2139}
2140
2141#[derive(Debug, Clone, PartialEq, Eq)]
2142pub struct CreateRecurrenceSeries {
2143    pub title: String,
2144    pub description: String,
2145    pub project: String,
2146    pub priority: TaskPriority,
2147    pub initial_status: TaskStatus,
2148    pub labels: Vec<String>,
2149    pub metadata: Vec<MetadataInput>,
2150    pub schedule: RecurrenceScheduleInput,
2151}
2152
2153#[derive(Debug, Clone, PartialEq, Eq, Default)]
2154pub enum OptionalLocalTimeUpdate {
2155    #[default]
2156    Unchanged,
2157    Set(String),
2158    Clear,
2159}
2160
2161impl OptionalLocalTimeUpdate {
2162    fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
2163        match self {
2164            Self::Unchanged => Ok(None),
2165            Self::Set(value) => Ok(Some(Some(parse_local_time(
2166                "available_local_time",
2167                &value,
2168            )?))),
2169            Self::Clear => Ok(Some(None)),
2170        }
2171    }
2172}
2173
2174#[derive(Debug, Clone, PartialEq, Eq, Default)]
2175pub struct UpdateRecurrenceTemplate {
2176    pub title: Option<String>,
2177    pub description: Option<String>,
2178    pub project: Option<String>,
2179    pub priority: Option<TaskPriority>,
2180    pub initial_status: Option<TaskStatus>,
2181    pub labels: Option<Vec<String>>,
2182    pub set_metadata: Vec<MetadataInput>,
2183    pub remove_metadata: Vec<String>,
2184    pub available_local_time: OptionalLocalTimeUpdate,
2185    pub due_policy: Option<RecurrenceDuePolicy>,
2186}
2187
2188#[derive(Debug, Clone, PartialEq, Eq)]
2189pub struct RecurrenceSeriesRecord {
2190    pub workspace_id: WorkspaceId,
2191    pub id: RecurrenceSeriesId,
2192    pub title: String,
2193    pub description: String,
2194    pub project_id: ProjectId,
2195    pub priority: TaskPriority,
2196    pub initial_status: TaskStatus,
2197    pub rule: RecurrenceRule,
2198    pub timezone: String,
2199    pub start_on: String,
2200    pub available_local_time: Option<String>,
2201    pub due_policy: RecurrenceDuePolicy,
2202    pub state: RecurrenceSeriesState,
2203    pub stopped_at: Option<String>,
2204    pub created_at: String,
2205    pub updated_at: String,
2206}
2207
2208impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
2209    fn from(value: RecurrenceSeries) -> Self {
2210        Self {
2211            workspace_id: value.workspace_id,
2212            id: value.id,
2213            title: value.title,
2214            description: value.description,
2215            project_id: value.project_id,
2216            priority: value.priority,
2217            initial_status: value.initial_status,
2218            rule: value.rule.into(),
2219            timezone: value.timezone.to_string(),
2220            start_on: value.start_on.to_string(),
2221            available_local_time: value
2222                .available_local_time
2223                .map(|time| time.format("%H:%M:%S").to_string()),
2224            due_policy: value.due_policy,
2225            state: value.state,
2226            stopped_at: value.stopped_at,
2227            created_at: value.created_at,
2228            updated_at: value.updated_at,
2229        }
2230    }
2231}
2232
2233#[derive(Debug, Clone, PartialEq, Eq)]
2234pub struct RecurrenceOccurrenceRecord {
2235    pub series_id: RecurrenceSeriesId,
2236    pub slot_on: String,
2237    pub task_id: Option<TaskId>,
2238    pub outcome: Option<RecurrenceOutcome>,
2239    pub resolved_at: Option<String>,
2240    pub projection_state: RecurrenceProjectionState,
2241    pub archived_at: Option<String>,
2242}
2243
2244impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
2245    fn from(value: RecurrenceOccurrence) -> Self {
2246        Self {
2247            series_id: value.series_id,
2248            slot_on: value.slot_on.to_string(),
2249            task_id: value.task_id,
2250            outcome: value.outcome,
2251            resolved_at: value.resolved_at,
2252            projection_state: value.projection_state,
2253            archived_at: value.archived_at,
2254        }
2255    }
2256}
2257
2258#[derive(Debug, Clone, PartialEq, Eq)]
2259pub struct RecurrenceCreateResult {
2260    pub series: RecurrenceSeriesRecord,
2261    pub series_ref: String,
2262    pub occurrence: RecurrenceOccurrenceRecord,
2263    pub task: TaskRecord,
2264}
2265
2266#[derive(Debug, Clone, PartialEq, Eq)]
2267pub struct RecurrenceTemplateUpdateResult {
2268    pub series: RecurrenceSeriesRecord,
2269    pub changed: bool,
2270}
2271
2272#[derive(Debug, Clone, PartialEq, Eq)]
2273pub struct RecurrenceResolveResult {
2274    pub series: RecurrenceSeriesRecord,
2275    pub occurrence: RecurrenceOccurrenceRecord,
2276    pub task: TaskRecord,
2277    pub successor: Option<TaskRecord>,
2278}
2279
2280#[derive(Debug, Clone, PartialEq, Eq)]
2281pub struct RecurrenceStateResult {
2282    pub series: RecurrenceSeriesRecord,
2283    pub occurrence: Option<RecurrenceOccurrenceRecord>,
2284}
2285
2286impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
2287    fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
2288        Self {
2289            series: value.series.into(),
2290            occurrence: value.occurrence.map(Into::into),
2291        }
2292    }
2293}
2294
2295#[derive(Debug, Clone, PartialEq, Eq)]
2296pub struct RecurrenceRefResolution {
2297    pub series_id: RecurrenceSeriesId,
2298    pub series_ref: String,
2299}
2300
2301#[derive(Debug, Clone, PartialEq, Eq, Default)]
2302pub struct RecurrenceCounts {
2303    pub completed: u64,
2304    pub skipped: u64,
2305    pub missed: u64,
2306    pub pause_intervals: u64,
2307    pub latest_slot_on: Option<String>,
2308    pub latest_outcome: Option<RecurrenceOutcome>,
2309}
2310
2311impl From<InternalRecurrenceCounts> for RecurrenceCounts {
2312    fn from(value: InternalRecurrenceCounts) -> Self {
2313        Self {
2314            completed: value.completed as u64,
2315            skipped: value.skipped as u64,
2316            missed: value.missed as u64,
2317            pause_intervals: value.pause_intervals as u64,
2318            latest_slot_on: value.latest_slot_on,
2319            latest_outcome: value.latest_outcome,
2320        }
2321    }
2322}
2323
2324#[derive(Debug, Clone, PartialEq, Eq)]
2325pub struct RecurrenceSeriesSummary {
2326    pub series: RecurrenceSeriesRecord,
2327    pub series_ref: String,
2328    pub rule_label: String,
2329    pub current_slot_on: Option<String>,
2330    pub current_task_ref: Option<String>,
2331    pub counts: RecurrenceCounts,
2332}
2333
2334impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
2335    fn from(value: InternalRecurrenceSeriesSummary) -> Self {
2336        Self {
2337            series: value.series.into(),
2338            series_ref: value.series_ref,
2339            rule_label: value.rule_label,
2340            current_slot_on: value.current_slot_on,
2341            current_task_ref: value.current_task_ref,
2342            counts: value.counts.into(),
2343        }
2344    }
2345}
2346
2347#[derive(Debug, Clone, PartialEq, Eq)]
2348pub struct RecurrenceSeriesConflict {
2349    pub field: String,
2350    pub variant_a: String,
2351    pub local_value: String,
2352    pub variant_b: String,
2353    pub remote_value: String,
2354}
2355
2356#[derive(Debug, Clone, PartialEq, Eq)]
2357pub struct RecurrenceSeriesDetail {
2358    pub series: RecurrenceSeriesRecord,
2359    pub labels: Vec<String>,
2360    pub metadata: Vec<MetadataValueRecord>,
2361    pub summary: RecurrenceSeriesSummary,
2362    pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
2363    pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
2364}
2365
2366impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
2367    fn from(value: InternalRecurrenceSeriesDetail) -> Self {
2368        Self {
2369            series: value.series.into(),
2370            labels: value.labels,
2371            metadata: value.metadata.into_iter().map(Into::into).collect(),
2372            summary: value.summary.into(),
2373            current_occurrence: value.current_occurrence.map(Into::into),
2374            lifecycle_conflicts: value
2375                .lifecycle_conflicts
2376                .into_iter()
2377                .map(|conflict| RecurrenceSeriesConflict {
2378                    field: conflict.field,
2379                    variant_a: conflict.variant_a,
2380                    local_value: conflict.local_value,
2381                    variant_b: conflict.variant_b,
2382                    remote_value: conflict.remote_value,
2383                })
2384                .collect(),
2385        }
2386    }
2387}
2388
2389#[derive(Debug, Clone, PartialEq, Eq)]
2390pub struct RecurrenceHistoryRow {
2391    pub kind: RecurrenceHistoryKind,
2392    pub slot_on: Option<String>,
2393    pub interval_started_at: Option<String>,
2394    pub interval_ended_at: Option<String>,
2395    pub task_id: Option<TaskId>,
2396    pub task_ref: Option<String>,
2397    pub openable: bool,
2398    pub archived_projection: bool,
2399    pub resolved_at: Option<String>,
2400}
2401
2402impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
2403    fn from(value: InternalRecurrenceHistoryEntry) -> Self {
2404        Self {
2405            kind: value.kind,
2406            slot_on: value.slot_on,
2407            interval_started_at: value.interval_started_at,
2408            interval_ended_at: value.interval_ended_at,
2409            task_id: value.task_id,
2410            task_ref: value.task_ref,
2411            openable: value.openable,
2412            archived_projection: value.archived_projection,
2413            resolved_at: value.resolved_at,
2414        }
2415    }
2416}
2417
2418#[derive(Debug, Clone, PartialEq, Eq)]
2419pub struct RecurrenceHistoryPage {
2420    pub series_ref: String,
2421    pub items: Vec<RecurrenceHistoryRow>,
2422    pub offset: usize,
2423    pub limit: usize,
2424    pub total: usize,
2425    pub has_more: bool,
2426}
2427
2428#[derive(Debug, Clone, PartialEq, Eq)]
2429pub struct TaskRecurrenceSummary {
2430    pub series_id: RecurrenceSeriesId,
2431    pub series_ref: String,
2432    pub slot_on: String,
2433    pub rule_label: String,
2434    pub timezone: String,
2435    pub lifecycle: RecurrenceSeriesState,
2436    pub outcome: Option<RecurrenceOutcome>,
2437    pub projection_state: RecurrenceProjectionState,
2438}
2439
2440#[derive(Debug, Clone, PartialEq, Eq)]
2441pub struct RecurrenceTaskGroup {
2442    pub series_id: RecurrenceSeriesId,
2443    pub series_ref: String,
2444    pub counts: RecurrenceCounts,
2445}
2446
2447#[derive(Debug, Clone, PartialEq, Eq)]
2448pub enum QueueDate {
2449    Due { on: String, kind: QueueDateKind },
2450    Deferred { at: String },
2451}
2452
2453#[derive(Debug, Clone, PartialEq, Eq)]
2454pub struct QueueLabelSummary {
2455    pub first: String,
2456    pub remaining_count: u32,
2457}
2458
2459#[derive(Debug, Clone, PartialEq, Eq)]
2460pub struct QueueTaskSummary {
2461    pub id: TaskId,
2462    pub title: String,
2463    pub display_ref: String,
2464    pub project_key: String,
2465    pub status: TaskStatus,
2466    pub priority: TaskPriority,
2467    pub due_on: Option<String>,
2468    pub band: QueueBand,
2469    pub reason: Option<QueueReason>,
2470    pub date: Option<QueueDate>,
2471    pub label: Option<QueueLabelSummary>,
2472    pub is_epic: bool,
2473    pub live_attachment_count: u32,
2474    pub has_conflict: bool,
2475    pub unresolved_blocker_count: i64,
2476}
2477
2478impl From<TaskListItem> for QueueTaskSummary {
2479    fn from(value: TaskListItem) -> Self {
2480        let date = if value.queue.has_deferred_date {
2481            value
2482                .task
2483                .available_at
2484                .clone()
2485                .map(|at| QueueDate::Deferred { at })
2486        } else {
2487            value.queue.date_kind.and_then(|kind| {
2488                value
2489                    .task
2490                    .due_on
2491                    .clone()
2492                    .map(|on| QueueDate::Due { on, kind })
2493            })
2494        };
2495        let label = value
2496            .labels
2497            .first()
2498            .cloned()
2499            .map(|first| QueueLabelSummary {
2500                first,
2501                remaining_count: value.labels.len().saturating_sub(1).min(u32::MAX as usize) as u32,
2502            });
2503        Self {
2504            id: value.task.id,
2505            title: value.task.title,
2506            display_ref: value.display_ref,
2507            project_key: value.task.project_key,
2508            status: value.task.status,
2509            priority: value.task.priority,
2510            due_on: value.task.due_on,
2511            band: value.queue.band,
2512            reason: value.queue.reason,
2513            date,
2514            label,
2515            is_epic: value.task.is_epic,
2516            live_attachment_count: value.live_attachment_count,
2517            has_conflict: value.has_conflict,
2518            unresolved_blocker_count: value.unresolved_blocker_count,
2519        }
2520    }
2521}
2522
2523#[derive(Debug, Clone, PartialEq, Eq)]
2524pub struct QueueReport {
2525    pub tasks: Vec<QueueTaskSummary>,
2526    pub unresolved_conflict_count: u32,
2527    pub last_success_at: Option<String>,
2528}
2529
2530#[derive(Debug, Clone, PartialEq, Eq)]
2531pub struct TaskSummary {
2532    pub task: TaskRecord,
2533    pub display_ref: String,
2534    pub recurrence: Option<TaskRecurrenceSummary>,
2535    pub recurrence_group: Option<RecurrenceTaskGroup>,
2536}
2537
2538impl From<TaskListItem> for TaskSummary {
2539    fn from(value: TaskListItem) -> Self {
2540        Self {
2541            task: value.task.into(),
2542            display_ref: value.display_ref,
2543            recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
2544                series_id: summary.series_id,
2545                series_ref: summary.series_ref,
2546                slot_on: summary.slot_on,
2547                rule_label: summary.rule_label,
2548                timezone: summary.timezone,
2549                lifecycle: summary.lifecycle,
2550                outcome: summary.outcome,
2551                projection_state: summary.projection_state,
2552            }),
2553            recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
2554                series_id: group.series_id,
2555                series_ref: group.series_ref,
2556                counts: group.counts.into(),
2557            }),
2558        }
2559    }
2560}
2561
2562#[derive(Debug, Clone, PartialEq, Eq)]
2563pub struct RelatedTaskRecord {
2564    pub task_id: TaskId,
2565    pub display_ref: String,
2566    pub title: String,
2567    pub status: TaskStatus,
2568    pub priority: TaskPriority,
2569    pub deleted: bool,
2570    pub linked_at: String,
2571}
2572
2573impl From<crate::query::TaskRelatedLink> for RelatedTaskRecord {
2574    fn from(value: crate::query::TaskRelatedLink) -> Self {
2575        Self {
2576            task_id: value.task_id,
2577            display_ref: value.display_ref,
2578            title: value.title,
2579            status: value.status,
2580            priority: value.priority,
2581            deleted: value.deleted,
2582            linked_at: value.linked_at,
2583        }
2584    }
2585}
2586
2587#[derive(Debug, Clone, PartialEq, Eq)]
2588pub struct RelatedMutationResult {
2589    pub changed: bool,
2590}
2591
2592#[derive(Debug, Clone, PartialEq, Eq)]
2593pub struct TaskRecord {
2594    pub id: TaskId,
2595    pub workspace_id: WorkspaceId,
2596    pub title: String,
2597    pub description: String,
2598    pub project_id: ProjectId,
2599    pub project_key: String,
2600    pub project_prefix: String,
2601    pub status: TaskStatus,
2602    pub priority: TaskPriority,
2603    pub created_at: String,
2604    pub updated_at: String,
2605    pub available_at: Option<String>,
2606    pub due_on: Option<String>,
2607    pub metadata: Vec<MetadataValueRecord>,
2608    pub related: Vec<RelatedTaskRecord>,
2609}
2610
2611impl From<Task> for TaskRecord {
2612    fn from(task: Task) -> Self {
2613        Self {
2614            id: task.id,
2615            workspace_id: task.workspace_id,
2616            title: task.title,
2617            description: task.description,
2618            project_id: task.project_id,
2619            project_key: task.project_key,
2620            project_prefix: task.project_prefix,
2621            status: task.status,
2622            priority: task.priority,
2623            created_at: task.created_at,
2624            updated_at: task.updated_at,
2625            available_at: task.available_at,
2626            due_on: task.due_on,
2627            metadata: Vec::new(),
2628            related: Vec::new(),
2629        }
2630    }
2631}
2632
2633impl TaskRecord {
2634    fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
2635        Self::with_metadata_and_related(task, metadata, Vec::new())
2636    }
2637
2638    fn with_metadata_and_related(
2639        task: Task,
2640        metadata: Vec<TaskMetadataValue>,
2641        related: Vec<crate::query::TaskRelatedLink>,
2642    ) -> Self {
2643        let mut record = Self::from(task);
2644        record.metadata = metadata.into_iter().map(Into::into).collect();
2645        record.related = related.into_iter().map(Into::into).collect();
2646        record
2647    }
2648}
2649
2650#[derive(Debug, Clone, PartialEq, Eq)]
2651pub struct TaskUpdateResult {
2652    pub task: TaskRecord,
2653    pub changed: bool,
2654}
2655
2656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2657pub enum ConflictField {
2658    Title,
2659    Description,
2660    Project,
2661    Status,
2662    Priority,
2663    AvailableAt,
2664    DueOn,
2665    Deleted,
2666    IsEpic,
2667}
2668
2669impl ConflictField {
2670    pub const fn as_str(self) -> &'static str {
2671        match self {
2672            Self::Title => "title",
2673            Self::Description => "description",
2674            Self::Project => "project",
2675            Self::Status => "status",
2676            Self::Priority => "priority",
2677            Self::AvailableAt => "available_at",
2678            Self::DueOn => "due_on",
2679            Self::Deleted => "deleted",
2680            Self::IsEpic => "is_epic",
2681        }
2682    }
2683
2684    fn from_task_field(field: TaskField) -> Self {
2685        match field {
2686            TaskField::Title => Self::Title,
2687            TaskField::Description => Self::Description,
2688            TaskField::Project => Self::Project,
2689            TaskField::Status => Self::Status,
2690            TaskField::Priority => Self::Priority,
2691            TaskField::AvailableAt => Self::AvailableAt,
2692            TaskField::DueOn => Self::DueOn,
2693            TaskField::Deleted => Self::Deleted,
2694            TaskField::IsEpic => Self::IsEpic,
2695        }
2696    }
2697}
2698
2699#[derive(Debug, Clone, PartialEq, Eq)]
2700pub struct ConflictSummary {
2701    pub task_id: TaskId,
2702    pub task_title: String,
2703    pub project_key: String,
2704    pub project_prefix: String,
2705    pub field: ConflictField,
2706}
2707
2708#[derive(Debug, Clone, PartialEq, Eq)]
2709pub struct Conflict {
2710    pub task_id: TaskId,
2711    pub field: ConflictField,
2712    pub local_value: String,
2713    pub remote_value: String,
2714    pub variant_a: String,
2715    pub variant_b: String,
2716}
2717
2718#[derive(Debug, Clone, PartialEq, Eq)]
2719pub enum ConflictResolution {
2720    Local,
2721    Remote,
2722    Explicit(String),
2723}
2724
2725fn bounded_conflict_display_value(value: String) -> String {
2726    const MAX_CHARACTERS: usize = 512;
2727    let mut characters = value.chars();
2728    let preview: String = characters.by_ref().take(MAX_CHARACTERS).collect();
2729    if characters.next().is_some() {
2730        format!("{preview}…")
2731    } else {
2732        preview
2733    }
2734}
2735
2736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2737pub enum ErrorCode {
2738    SyncCompatibility,
2739    Validation,
2740    NotFound,
2741    OpenConflict,
2742    GenerationConflict,
2743    Database,
2744    Internal,
2745}
2746
2747impl ErrorCode {
2748    pub const fn as_str(self) -> &'static str {
2749        match self {
2750            Self::SyncCompatibility => "sync_compatibility",
2751            Self::Validation => "validation",
2752            Self::NotFound => "not_found",
2753            Self::OpenConflict => "open_conflict",
2754            Self::GenerationConflict => "generation_conflict",
2755            Self::Database => "database",
2756            Self::Internal => "internal",
2757        }
2758    }
2759}
2760
2761#[derive(Debug, Clone, PartialEq, Eq)]
2762pub struct Error {
2763    pub code: ErrorCode,
2764    pub message: String,
2765}
2766
2767impl Error {
2768    fn new(code: ErrorCode, message: String) -> Self {
2769        Self { code, message }
2770    }
2771
2772    fn database_open(error: InternalError) -> Self {
2773        Self::new(ErrorCode::Database, error.to_string())
2774    }
2775
2776    fn from_internal(error: InternalError) -> Self {
2777        if error
2778            .downcast_ref::<crate::sync::protocol::SyncCompatibilityError>()
2779            .is_some()
2780            || error
2781                .downcast_ref::<crate::sync::protocol::SharedOperationCompatibilityError>()
2782                .is_some()
2783        {
2784            return Self::new(ErrorCode::SyncCompatibility, error.to_string());
2785        }
2786        let code = error
2787            .chain()
2788            .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
2789            .map(|error| match error.kind() {
2790                crate::error::ErrorKind::Validation => ErrorCode::Validation,
2791                crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
2792                crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
2793                crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
2794            })
2795            .unwrap_or_else(|| {
2796                if error
2797                    .chain()
2798                    .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
2799                    .any(|error| matches!(error, sqlx::Error::RowNotFound))
2800                {
2801                    ErrorCode::NotFound
2802                } else if error
2803                    .chain()
2804                    .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
2805                {
2806                    ErrorCode::Database
2807                } else {
2808                    ErrorCode::Internal
2809                }
2810            });
2811        Self::new(code, error.to_string())
2812    }
2813}
2814
2815impl fmt::Display for Error {
2816    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2817        write!(formatter, "{}: {}", self.code.as_str(), self.message)
2818    }
2819}
2820
2821impl std::error::Error for Error {}
2822
2823#[cfg(test)]
2824mod tests {
2825    use super::*;
2826
2827    #[tokio::test]
2828    async fn queue_capture_receipt_survives_post_commit_read_failure() {
2829        let directory = tempfile::tempdir().unwrap();
2830        let store = Store::open(directory.path().join("capture.sqlite"))
2831            .await
2832            .unwrap();
2833        let workspace = store.resolve_workspace("default").await.unwrap();
2834        store
2835            .create_task(
2836                &workspace.id,
2837                CreateTask {
2838                    title: "Seed".into(),
2839                    description: String::new(),
2840                    project: "ios".into(),
2841                    status: TaskStatus::Done,
2842                    priority: TaskPriority::None,
2843                    metadata: Vec::new(),
2844                    available_at: None,
2845                    due_on: None,
2846                },
2847            )
2848            .await
2849            .unwrap();
2850        store
2851            .fail_queue_reads
2852            .store(true, std::sync::atomic::Ordering::SeqCst);
2853        let result = store
2854            .capture_queue_task(
2855                &workspace.id,
2856                TaskCapture {
2857                    title: "Captured".into(),
2858                    description: String::new(),
2859                    project: Some("ios".into()),
2860                    status: TaskStatus::Inbox,
2861                    priority: TaskPriority::None,
2862                    source: TaskSource::Ios,
2863                    due_on: None,
2864                    labels: Vec::new(),
2865                },
2866            )
2867            .await;
2868        let tasks = store.list_tasks(&workspace.id).await.unwrap();
2869        assert_eq!(
2870            tasks.iter().filter(|task| task.title == "Captured").count(),
2871            1
2872        );
2873        assert!(
2874            result.is_ok(),
2875            "committed capture lost its receipt: {result:?}"
2876        );
2877        let captured = result.unwrap();
2878        assert!(captured.state.is_none());
2879        assert_eq!(captured.display_ref, captured.task_id.to_string());
2880        assert!(!captured.undo_token.is_empty());
2881        let mutation_target = store
2882            .capture_queue_task(
2883                &workspace.id,
2884                TaskCapture {
2885                    title: "Mutation target".into(),
2886                    description: String::new(),
2887                    project: Some("ios".into()),
2888                    status: TaskStatus::Inbox,
2889                    priority: TaskPriority::None,
2890                    source: TaskSource::Ios,
2891                    due_on: None,
2892                    labels: Vec::new(),
2893                },
2894            )
2895            .await
2896            .unwrap();
2897        // Mutations can invalidate capture undo even after their values are restored.
2898        let changed = store
2899            .mutate_queue_task(
2900                &workspace.id,
2901                &mutation_target.task_id,
2902                QueueMutation {
2903                    kind: QueueMutationKind::SetPriority,
2904                    priority: Some(TaskPriority::High),
2905                    local_date: None,
2906                    time_zone: None,
2907                },
2908            )
2909            .await
2910            .unwrap();
2911        assert_eq!(changed.task_id, mutation_target.task_id);
2912        assert!(changed.state.is_none());
2913        assert_eq!(
2914            store
2915                .task_detail(&workspace.id, &mutation_target.task_id)
2916                .await
2917                .unwrap()
2918                .priority,
2919            TaskPriority::High
2920        );
2921        assert!(
2922            store
2923                .undo_queue_mutation(&changed.undo_token)
2924                .await
2925                .unwrap()
2926                .is_none()
2927        );
2928        assert_eq!(
2929            store
2930                .task_detail(&workspace.id, &mutation_target.task_id)
2931                .await
2932                .unwrap()
2933                .priority,
2934            TaskPriority::None
2935        );
2936        assert!(
2937            store
2938                .undo_queue_capture(&captured.undo_token)
2939                .await
2940                .unwrap()
2941                .is_none()
2942        );
2943        store
2944            .fail_queue_reads
2945            .store(false, std::sync::atomic::Ordering::SeqCst);
2946        let state = store.queue_state().await.unwrap();
2947        assert!(
2948            state
2949                .queue
2950                .tasks
2951                .iter()
2952                .all(|task| task.id != captured.task_id)
2953        );
2954    }
2955
2956    #[test]
2957    fn error_codes_are_stable_and_internal_errors_are_distinct() {
2958        assert_eq!(ErrorCode::Validation.as_str(), "validation");
2959        assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
2960        assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
2961        assert_eq!(
2962            ErrorCode::GenerationConflict.as_str(),
2963            "generation_conflict"
2964        );
2965        assert_eq!(ErrorCode::Database.as_str(), "database");
2966        assert_eq!(ErrorCode::Internal.as_str(), "internal");
2967        assert_eq!(
2968            Error::from_internal(
2969                crate::error::CoreError::generation_conflict(
2970                    "error recurrence-generation-conflict slot=2026-08-01 field=task"
2971                )
2972                .into()
2973            )
2974            .code,
2975            ErrorCode::GenerationConflict
2976        );
2977        assert_eq!(
2978            Error::from_internal(
2979                crate::error::CoreError::not_found("error recurrence-series-not-found").into()
2980            )
2981            .code,
2982            ErrorCode::NotFound
2983        );
2984        assert_eq!(
2985            Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
2986            ErrorCode::Internal
2987        );
2988        assert_eq!(
2989            Error::from_internal(sqlx::Error::PoolClosed.into()).code,
2990            ErrorCode::Database
2991        );
2992    }
2993}