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