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: TaskStatus::Inbox.as_str().to_string(),
422 priority: input.priority.as_str().to_string(),
423 source: TaskSource::Api,
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 priority: TaskPriority,
1740 pub due_on: Option<String>,
1741 pub labels: Vec<String>,
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq)]
1745pub struct IosTaskCaptureResult {
1746 pub task_id: TaskId,
1747 pub display_ref: String,
1748 pub undo_token: String,
1749 pub state: Option<IosQueueState>,
1751}
1752
1753#[derive(serde::Serialize, serde::Deserialize)]
1754struct IosCaptureUndoToken {
1755 workspace_id: WorkspaceId,
1756 task_id: TaskId,
1757 expected: TaskUndoSnapshot,
1758}
1759
1760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1761pub enum IosQueueMutationKind {
1762 Start,
1763 Done,
1764 Snooze,
1765 SetPriority,
1766}
1767
1768#[derive(Debug, Clone, PartialEq, Eq)]
1769pub struct IosQueueMutation {
1770 pub kind: IosQueueMutationKind,
1771 pub priority: Option<TaskPriority>,
1772 pub local_date: Option<String>,
1773 pub time_zone: Option<String>,
1774}
1775
1776impl IosQueueMutation {
1777 fn into_internal(self) -> Result<InternalIosTaskMutation, Error> {
1778 match self.kind {
1779 IosQueueMutationKind::Start => Ok(InternalIosTaskMutation::Start),
1780 IosQueueMutationKind::Done => Ok(InternalIosTaskMutation::Done),
1781 IosQueueMutationKind::SetPriority => self
1782 .priority
1783 .map(|priority| InternalIosTaskMutation::SetPriority {
1784 priority: priority.as_str().to_string(),
1785 })
1786 .ok_or_else(|| {
1787 Error::new(
1788 ErrorCode::Validation,
1789 "priority is required for this quick action".to_string(),
1790 )
1791 }),
1792 IosQueueMutationKind::Snooze => {
1793 let local_date = self.local_date.ok_or_else(|| {
1794 Error::new(
1795 ErrorCode::Validation,
1796 "local date is required for snooze".to_string(),
1797 )
1798 })?;
1799 let time_zone = self.time_zone.ok_or_else(|| {
1800 Error::new(
1801 ErrorCode::Validation,
1802 "time zone is required for snooze".to_string(),
1803 )
1804 })?;
1805 Ok(InternalIosTaskMutation::Snooze {
1806 available_at: tomorrow_start(&local_date, &time_zone)?,
1807 })
1808 }
1809 }
1810 }
1811}
1812
1813#[derive(Debug, Clone, PartialEq, Eq)]
1814pub struct IosQueueMutationResult {
1815 pub task_id: TaskId,
1816 pub undo_token: String,
1817 pub state: Option<IosQueueState>,
1819}
1820
1821#[derive(Debug, Clone, PartialEq, Eq)]
1822pub struct IosDetailStatusReceipt {
1823 pub status: TaskStatus,
1824 pub undo_token: Option<String>,
1825}
1826
1827#[derive(serde::Serialize, serde::Deserialize)]
1828struct IosMutationUndoToken {
1829 workspace_id: WorkspaceId,
1830 task_id: TaskId,
1831 mutation: InternalIosTaskMutation,
1832 before: TaskUndoSnapshot,
1833 expected: TaskUndoSnapshot,
1834}
1835
1836fn tomorrow_start(local_date: &str, time_zone: &str) -> Result<String, Error> {
1837 let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d")
1838 .map_err(|_| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?
1839 .checked_add_days(Days::new(1))
1840 .ok_or_else(|| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?;
1841 let zone = time_zone
1842 .parse::<chrono_tz::Tz>()
1843 .map_err(|_| Error::new(ErrorCode::Validation, "invalid IANA time zone".to_string()))?;
1844 let mut local = date.and_time(NaiveTime::MIN);
1845 let resolved = loop {
1846 match zone.from_local_datetime(&local) {
1847 LocalResult::Single(value) => break value,
1848 LocalResult::Ambiguous(first, second) => break first.min(second),
1849 LocalResult::None => {
1850 local = local
1851 .checked_add_signed(chrono::Duration::minutes(1))
1852 .ok_or_else(|| {
1853 Error::new(ErrorCode::Validation, "invalid local date".to_string())
1854 })?;
1855 if local.date() != date {
1856 return Err(Error::new(
1857 ErrorCode::Validation,
1858 "time zone has no valid instant on the next local day".to_string(),
1859 ));
1860 }
1861 }
1862 }
1863 };
1864 Ok(resolved
1865 .with_timezone(&Utc)
1866 .format("%Y-%m-%dT%H:%M:%SZ")
1867 .to_string())
1868}
1869
1870#[derive(Debug, Clone, PartialEq, Eq)]
1871pub struct MetadataFieldRecord {
1872 pub id: MetadataFieldId,
1873 pub workspace_id: WorkspaceId,
1874 pub key: String,
1875 pub task_count: usize,
1876 pub series_count: usize,
1877}
1878
1879impl From<MetadataFieldUsage> for MetadataFieldRecord {
1880 fn from(value: MetadataFieldUsage) -> Self {
1881 Self {
1882 id: value.field.id,
1883 workspace_id: value.field.workspace_id,
1884 key: value.field.key,
1885 task_count: value.task_count,
1886 series_count: value.series_count,
1887 }
1888 }
1889}
1890
1891#[derive(Debug, Clone, PartialEq, Eq)]
1892pub struct MetadataValueRecord {
1893 pub field_id: MetadataFieldId,
1894 pub key: String,
1895 pub value: String,
1896}
1897
1898impl From<TaskMetadataValue> for MetadataValueRecord {
1899 fn from(value: TaskMetadataValue) -> Self {
1900 Self {
1901 field_id: value.field_id,
1902 key: value.key,
1903 value: value.value,
1904 }
1905 }
1906}
1907
1908#[derive(Debug, Clone, PartialEq, Eq)]
1909pub struct MetadataInput {
1910 pub key: String,
1911 pub value: String,
1912}
1913
1914impl From<MetadataInput> for TaskMetadataInput {
1915 fn from(input: MetadataInput) -> Self {
1916 Self {
1917 expected_field_id: None,
1918 key: input.key,
1919 value: input.value,
1920 }
1921 }
1922}
1923
1924#[derive(Debug, Clone, PartialEq, Eq)]
1925pub struct CreateTask {
1926 pub title: String,
1927 pub description: String,
1928 pub project: String,
1929 pub status: TaskStatus,
1930 pub priority: TaskPriority,
1931 pub metadata: Vec<MetadataInput>,
1932 pub available_at: Option<String>,
1933 pub due_on: Option<String>,
1934}
1935
1936#[derive(Debug, Clone, PartialEq, Eq)]
1937pub struct IosTaskEditContext {
1938 pub detail: IosTaskDetail,
1939 pub projects: Vec<ProjectRecord>,
1940 pub labels: Vec<String>,
1941}
1942
1943#[derive(Debug, Clone, PartialEq, Eq, Default)]
1944pub struct IosTaskEdit {
1945 pub title: Option<String>,
1946 pub description: Option<String>,
1947 pub project_id: Option<ProjectId>,
1948 pub status: Option<TaskStatus>,
1949 pub priority: Option<TaskPriority>,
1950 pub add_labels: Vec<String>,
1951 pub remove_labels: Vec<String>,
1952 pub available_at: OptionalDateUpdate,
1953 pub due_on: OptionalDateUpdate,
1954}
1955
1956#[derive(Debug, Clone, PartialEq, Eq, Default)]
1957pub struct UpdateTask {
1958 pub title: Option<String>,
1959 pub description: Option<String>,
1960 pub project: Option<String>,
1961 pub status: Option<TaskStatus>,
1962 pub priority: Option<TaskPriority>,
1963 pub set_metadata: Vec<MetadataInput>,
1964 pub remove_metadata: Vec<String>,
1965 pub available_at: OptionalDateUpdate,
1966 pub due_on: OptionalDateUpdate,
1967}
1968
1969#[derive(Debug, Clone, PartialEq, Eq, Default)]
1970pub enum OptionalDateUpdate {
1971 #[default]
1972 Unchanged,
1973 Set(String),
1974 Clear,
1975}
1976
1977impl OptionalDateUpdate {
1978 fn into_internal(self) -> Option<Option<String>> {
1979 match self {
1980 Self::Unchanged => None,
1981 Self::Set(value) => Some(Some(value)),
1982 Self::Clear => Some(None),
1983 }
1984 }
1985}
1986
1987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1988pub enum RecurrenceWeekday {
1989 Monday,
1990 Tuesday,
1991 Wednesday,
1992 Thursday,
1993 Friday,
1994 Saturday,
1995 Sunday,
1996}
1997
1998impl From<RecurrenceWeekday> for Weekday {
1999 fn from(value: RecurrenceWeekday) -> Self {
2000 match value {
2001 RecurrenceWeekday::Monday => Self::Mon,
2002 RecurrenceWeekday::Tuesday => Self::Tue,
2003 RecurrenceWeekday::Wednesday => Self::Wed,
2004 RecurrenceWeekday::Thursday => Self::Thu,
2005 RecurrenceWeekday::Friday => Self::Fri,
2006 RecurrenceWeekday::Saturday => Self::Sat,
2007 RecurrenceWeekday::Sunday => Self::Sun,
2008 }
2009 }
2010}
2011
2012impl From<Weekday> for RecurrenceWeekday {
2013 fn from(value: Weekday) -> Self {
2014 match value {
2015 Weekday::Mon => Self::Monday,
2016 Weekday::Tue => Self::Tuesday,
2017 Weekday::Wed => Self::Wednesday,
2018 Weekday::Thu => Self::Thursday,
2019 Weekday::Fri => Self::Friday,
2020 Weekday::Sat => Self::Saturday,
2021 Weekday::Sun => Self::Sunday,
2022 }
2023 }
2024}
2025
2026#[derive(Debug, Clone, PartialEq, Eq)]
2027pub struct RecurrenceRule {
2028 pub frequency: RecurrenceFrequency,
2029 pub interval: u32,
2030 pub weekdays: Vec<RecurrenceWeekday>,
2031}
2032
2033impl RecurrenceRule {
2034 fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
2035 InternalRecurrenceRule::new(
2036 self.frequency,
2037 self.interval,
2038 WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
2039 )
2040 .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
2041 }
2042}
2043
2044impl From<InternalRecurrenceRule> for RecurrenceRule {
2045 fn from(value: InternalRecurrenceRule) -> Self {
2046 Self {
2047 frequency: value.frequency(),
2048 interval: value.interval(),
2049 weekdays: value.weekdays_set().iter().map(Into::into).collect(),
2050 }
2051 }
2052}
2053
2054#[derive(Debug, Clone, PartialEq, Eq)]
2055pub struct RecurrenceScheduleInput {
2056 pub rule: RecurrenceRule,
2057 pub timezone: String,
2058 pub start_on: String,
2059 pub available_local_time: Option<String>,
2060 pub due_policy: RecurrenceDuePolicy,
2061}
2062
2063impl RecurrenceScheduleInput {
2064 fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
2065 let timezone = self
2066 .timezone
2067 .parse::<TimeZoneId>()
2068 .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
2069 let start_on = parse_date("start_on", &self.start_on)?;
2070 let available_local_time = self
2071 .available_local_time
2072 .map(|value| parse_local_time("available_local_time", &value))
2073 .transpose()?;
2074 Ok(RecurrenceSchedule::new(
2075 self.rule.into_internal()?,
2076 timezone,
2077 start_on,
2078 available_local_time,
2079 self.due_policy,
2080 ))
2081 }
2082}
2083
2084#[derive(Debug, Clone, PartialEq, Eq)]
2085pub struct CreateRecurrenceSeries {
2086 pub title: String,
2087 pub description: String,
2088 pub project: String,
2089 pub priority: TaskPriority,
2090 pub initial_status: TaskStatus,
2091 pub labels: Vec<String>,
2092 pub metadata: Vec<MetadataInput>,
2093 pub schedule: RecurrenceScheduleInput,
2094}
2095
2096#[derive(Debug, Clone, PartialEq, Eq, Default)]
2097pub enum OptionalLocalTimeUpdate {
2098 #[default]
2099 Unchanged,
2100 Set(String),
2101 Clear,
2102}
2103
2104impl OptionalLocalTimeUpdate {
2105 fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
2106 match self {
2107 Self::Unchanged => Ok(None),
2108 Self::Set(value) => Ok(Some(Some(parse_local_time(
2109 "available_local_time",
2110 &value,
2111 )?))),
2112 Self::Clear => Ok(Some(None)),
2113 }
2114 }
2115}
2116
2117#[derive(Debug, Clone, PartialEq, Eq, Default)]
2118pub struct UpdateRecurrenceTemplate {
2119 pub title: Option<String>,
2120 pub description: Option<String>,
2121 pub project: Option<String>,
2122 pub priority: Option<TaskPriority>,
2123 pub initial_status: Option<TaskStatus>,
2124 pub labels: Option<Vec<String>>,
2125 pub set_metadata: Vec<MetadataInput>,
2126 pub remove_metadata: Vec<String>,
2127 pub available_local_time: OptionalLocalTimeUpdate,
2128 pub due_policy: Option<RecurrenceDuePolicy>,
2129}
2130
2131#[derive(Debug, Clone, PartialEq, Eq)]
2132pub struct RecurrenceSeriesRecord {
2133 pub workspace_id: WorkspaceId,
2134 pub id: RecurrenceSeriesId,
2135 pub title: String,
2136 pub description: String,
2137 pub project_id: ProjectId,
2138 pub priority: TaskPriority,
2139 pub initial_status: TaskStatus,
2140 pub rule: RecurrenceRule,
2141 pub timezone: String,
2142 pub start_on: String,
2143 pub available_local_time: Option<String>,
2144 pub due_policy: RecurrenceDuePolicy,
2145 pub state: RecurrenceSeriesState,
2146 pub stopped_at: Option<String>,
2147 pub created_at: String,
2148 pub updated_at: String,
2149}
2150
2151impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
2152 fn from(value: RecurrenceSeries) -> Self {
2153 Self {
2154 workspace_id: value.workspace_id,
2155 id: value.id,
2156 title: value.title,
2157 description: value.description,
2158 project_id: value.project_id,
2159 priority: value.priority,
2160 initial_status: value.initial_status,
2161 rule: value.rule.into(),
2162 timezone: value.timezone.to_string(),
2163 start_on: value.start_on.to_string(),
2164 available_local_time: value
2165 .available_local_time
2166 .map(|time| time.format("%H:%M:%S").to_string()),
2167 due_policy: value.due_policy,
2168 state: value.state,
2169 stopped_at: value.stopped_at,
2170 created_at: value.created_at,
2171 updated_at: value.updated_at,
2172 }
2173 }
2174}
2175
2176#[derive(Debug, Clone, PartialEq, Eq)]
2177pub struct RecurrenceOccurrenceRecord {
2178 pub series_id: RecurrenceSeriesId,
2179 pub slot_on: String,
2180 pub task_id: Option<TaskId>,
2181 pub outcome: Option<RecurrenceOutcome>,
2182 pub resolved_at: Option<String>,
2183 pub projection_state: RecurrenceProjectionState,
2184 pub archived_at: Option<String>,
2185}
2186
2187impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
2188 fn from(value: RecurrenceOccurrence) -> Self {
2189 Self {
2190 series_id: value.series_id,
2191 slot_on: value.slot_on.to_string(),
2192 task_id: value.task_id,
2193 outcome: value.outcome,
2194 resolved_at: value.resolved_at,
2195 projection_state: value.projection_state,
2196 archived_at: value.archived_at,
2197 }
2198 }
2199}
2200
2201#[derive(Debug, Clone, PartialEq, Eq)]
2202pub struct RecurrenceCreateResult {
2203 pub series: RecurrenceSeriesRecord,
2204 pub series_ref: String,
2205 pub occurrence: RecurrenceOccurrenceRecord,
2206 pub task: TaskRecord,
2207}
2208
2209#[derive(Debug, Clone, PartialEq, Eq)]
2210pub struct RecurrenceTemplateUpdateResult {
2211 pub series: RecurrenceSeriesRecord,
2212 pub changed: bool,
2213}
2214
2215#[derive(Debug, Clone, PartialEq, Eq)]
2216pub struct RecurrenceResolveResult {
2217 pub series: RecurrenceSeriesRecord,
2218 pub occurrence: RecurrenceOccurrenceRecord,
2219 pub task: TaskRecord,
2220 pub successor: Option<TaskRecord>,
2221}
2222
2223#[derive(Debug, Clone, PartialEq, Eq)]
2224pub struct RecurrenceStateResult {
2225 pub series: RecurrenceSeriesRecord,
2226 pub occurrence: Option<RecurrenceOccurrenceRecord>,
2227}
2228
2229impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
2230 fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
2231 Self {
2232 series: value.series.into(),
2233 occurrence: value.occurrence.map(Into::into),
2234 }
2235 }
2236}
2237
2238#[derive(Debug, Clone, PartialEq, Eq)]
2239pub struct RecurrenceRefResolution {
2240 pub series_id: RecurrenceSeriesId,
2241 pub series_ref: String,
2242}
2243
2244#[derive(Debug, Clone, PartialEq, Eq, Default)]
2245pub struct RecurrenceCounts {
2246 pub completed: u64,
2247 pub skipped: u64,
2248 pub missed: u64,
2249 pub pause_intervals: u64,
2250 pub latest_slot_on: Option<String>,
2251 pub latest_outcome: Option<RecurrenceOutcome>,
2252}
2253
2254impl From<InternalRecurrenceCounts> for RecurrenceCounts {
2255 fn from(value: InternalRecurrenceCounts) -> Self {
2256 Self {
2257 completed: value.completed as u64,
2258 skipped: value.skipped as u64,
2259 missed: value.missed as u64,
2260 pause_intervals: value.pause_intervals as u64,
2261 latest_slot_on: value.latest_slot_on,
2262 latest_outcome: value.latest_outcome,
2263 }
2264 }
2265}
2266
2267#[derive(Debug, Clone, PartialEq, Eq)]
2268pub struct RecurrenceSeriesSummary {
2269 pub series: RecurrenceSeriesRecord,
2270 pub series_ref: String,
2271 pub rule_label: String,
2272 pub current_slot_on: Option<String>,
2273 pub current_task_ref: Option<String>,
2274 pub counts: RecurrenceCounts,
2275}
2276
2277impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
2278 fn from(value: InternalRecurrenceSeriesSummary) -> Self {
2279 Self {
2280 series: value.series.into(),
2281 series_ref: value.series_ref,
2282 rule_label: value.rule_label,
2283 current_slot_on: value.current_slot_on,
2284 current_task_ref: value.current_task_ref,
2285 counts: value.counts.into(),
2286 }
2287 }
2288}
2289
2290#[derive(Debug, Clone, PartialEq, Eq)]
2291pub struct RecurrenceSeriesConflict {
2292 pub field: String,
2293 pub variant_a: String,
2294 pub local_value: String,
2295 pub variant_b: String,
2296 pub remote_value: String,
2297}
2298
2299#[derive(Debug, Clone, PartialEq, Eq)]
2300pub struct RecurrenceSeriesDetail {
2301 pub series: RecurrenceSeriesRecord,
2302 pub labels: Vec<String>,
2303 pub metadata: Vec<MetadataValueRecord>,
2304 pub summary: RecurrenceSeriesSummary,
2305 pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
2306 pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
2307}
2308
2309impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
2310 fn from(value: InternalRecurrenceSeriesDetail) -> Self {
2311 Self {
2312 series: value.series.into(),
2313 labels: value.labels,
2314 metadata: value.metadata.into_iter().map(Into::into).collect(),
2315 summary: value.summary.into(),
2316 current_occurrence: value.current_occurrence.map(Into::into),
2317 lifecycle_conflicts: value
2318 .lifecycle_conflicts
2319 .into_iter()
2320 .map(|conflict| RecurrenceSeriesConflict {
2321 field: conflict.field,
2322 variant_a: conflict.variant_a,
2323 local_value: conflict.local_value,
2324 variant_b: conflict.variant_b,
2325 remote_value: conflict.remote_value,
2326 })
2327 .collect(),
2328 }
2329 }
2330}
2331
2332#[derive(Debug, Clone, PartialEq, Eq)]
2333pub struct RecurrenceHistoryRow {
2334 pub kind: RecurrenceHistoryKind,
2335 pub slot_on: Option<String>,
2336 pub interval_started_at: Option<String>,
2337 pub interval_ended_at: Option<String>,
2338 pub task_id: Option<TaskId>,
2339 pub task_ref: Option<String>,
2340 pub openable: bool,
2341 pub archived_projection: bool,
2342 pub resolved_at: Option<String>,
2343}
2344
2345impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
2346 fn from(value: InternalRecurrenceHistoryEntry) -> Self {
2347 Self {
2348 kind: value.kind,
2349 slot_on: value.slot_on,
2350 interval_started_at: value.interval_started_at,
2351 interval_ended_at: value.interval_ended_at,
2352 task_id: value.task_id,
2353 task_ref: value.task_ref,
2354 openable: value.openable,
2355 archived_projection: value.archived_projection,
2356 resolved_at: value.resolved_at,
2357 }
2358 }
2359}
2360
2361#[derive(Debug, Clone, PartialEq, Eq)]
2362pub struct RecurrenceHistoryPage {
2363 pub series_ref: String,
2364 pub items: Vec<RecurrenceHistoryRow>,
2365 pub offset: usize,
2366 pub limit: usize,
2367 pub total: usize,
2368 pub has_more: bool,
2369}
2370
2371#[derive(Debug, Clone, PartialEq, Eq)]
2372pub struct TaskRecurrenceSummary {
2373 pub series_id: RecurrenceSeriesId,
2374 pub series_ref: String,
2375 pub slot_on: String,
2376 pub rule_label: String,
2377 pub timezone: String,
2378 pub lifecycle: RecurrenceSeriesState,
2379 pub outcome: Option<RecurrenceOutcome>,
2380 pub projection_state: RecurrenceProjectionState,
2381}
2382
2383#[derive(Debug, Clone, PartialEq, Eq)]
2384pub struct RecurrenceTaskGroup {
2385 pub series_id: RecurrenceSeriesId,
2386 pub series_ref: String,
2387 pub counts: RecurrenceCounts,
2388}
2389
2390#[derive(Debug, Clone, PartialEq, Eq)]
2391pub enum QueueDate {
2392 Due { on: String, kind: QueueDateKind },
2393 Deferred { at: String },
2394}
2395
2396#[derive(Debug, Clone, PartialEq, Eq)]
2397pub struct QueueLabelSummary {
2398 pub first: String,
2399 pub remaining_count: u32,
2400}
2401
2402#[derive(Debug, Clone, PartialEq, Eq)]
2403pub struct QueueTaskSummary {
2404 pub id: TaskId,
2405 pub title: String,
2406 pub display_ref: String,
2407 pub project_key: String,
2408 pub status: TaskStatus,
2409 pub priority: TaskPriority,
2410 pub due_on: Option<String>,
2411 pub band: QueueBand,
2412 pub reason: Option<QueueReason>,
2413 pub date: Option<QueueDate>,
2414 pub label: Option<QueueLabelSummary>,
2415 pub is_epic: bool,
2416 pub live_attachment_count: u32,
2417 pub has_conflict: bool,
2418 pub unresolved_blocker_count: i64,
2419}
2420
2421impl From<TaskListItem> for QueueTaskSummary {
2422 fn from(value: TaskListItem) -> Self {
2423 let date = if value.queue.has_deferred_date {
2424 value
2425 .task
2426 .available_at
2427 .clone()
2428 .map(|at| QueueDate::Deferred { at })
2429 } else {
2430 value.queue.date_kind.and_then(|kind| {
2431 value
2432 .task
2433 .due_on
2434 .clone()
2435 .map(|on| QueueDate::Due { on, kind })
2436 })
2437 };
2438 let label = value
2439 .labels
2440 .first()
2441 .cloned()
2442 .map(|first| QueueLabelSummary {
2443 first,
2444 remaining_count: value.labels.len().saturating_sub(1).min(u32::MAX as usize) as u32,
2445 });
2446 Self {
2447 id: value.task.id,
2448 title: value.task.title,
2449 display_ref: value.display_ref,
2450 project_key: value.task.project_key,
2451 status: value.task.status,
2452 priority: value.task.priority,
2453 due_on: value.task.due_on,
2454 band: value.queue.band,
2455 reason: value.queue.reason,
2456 date,
2457 label,
2458 is_epic: value.task.is_epic,
2459 live_attachment_count: value.live_attachment_count,
2460 has_conflict: value.has_conflict,
2461 unresolved_blocker_count: value.unresolved_blocker_count,
2462 }
2463 }
2464}
2465
2466#[derive(Debug, Clone, PartialEq, Eq)]
2467pub struct QueueReport {
2468 pub tasks: Vec<QueueTaskSummary>,
2469 pub unresolved_conflict_count: u32,
2470 pub last_success_at: Option<String>,
2471}
2472
2473#[derive(Debug, Clone, PartialEq, Eq)]
2474pub struct TaskSummary {
2475 pub task: TaskRecord,
2476 pub display_ref: String,
2477 pub recurrence: Option<TaskRecurrenceSummary>,
2478 pub recurrence_group: Option<RecurrenceTaskGroup>,
2479}
2480
2481impl From<TaskListItem> for TaskSummary {
2482 fn from(value: TaskListItem) -> Self {
2483 Self {
2484 task: value.task.into(),
2485 display_ref: value.display_ref,
2486 recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
2487 series_id: summary.series_id,
2488 series_ref: summary.series_ref,
2489 slot_on: summary.slot_on,
2490 rule_label: summary.rule_label,
2491 timezone: summary.timezone,
2492 lifecycle: summary.lifecycle,
2493 outcome: summary.outcome,
2494 projection_state: summary.projection_state,
2495 }),
2496 recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
2497 series_id: group.series_id,
2498 series_ref: group.series_ref,
2499 counts: group.counts.into(),
2500 }),
2501 }
2502 }
2503}
2504
2505#[derive(Debug, Clone, PartialEq, Eq)]
2506pub struct RelatedTaskRecord {
2507 pub task_id: TaskId,
2508 pub display_ref: String,
2509 pub title: String,
2510 pub status: TaskStatus,
2511 pub priority: TaskPriority,
2512 pub deleted: bool,
2513 pub linked_at: String,
2514}
2515
2516impl From<crate::query::TaskRelatedLink> for RelatedTaskRecord {
2517 fn from(value: crate::query::TaskRelatedLink) -> Self {
2518 Self {
2519 task_id: value.task_id,
2520 display_ref: value.display_ref,
2521 title: value.title,
2522 status: value.status,
2523 priority: value.priority,
2524 deleted: value.deleted,
2525 linked_at: value.linked_at,
2526 }
2527 }
2528}
2529
2530#[derive(Debug, Clone, PartialEq, Eq)]
2531pub struct RelatedMutationResult {
2532 pub changed: bool,
2533}
2534
2535#[derive(Debug, Clone, PartialEq, Eq)]
2536pub struct TaskRecord {
2537 pub id: TaskId,
2538 pub workspace_id: WorkspaceId,
2539 pub title: String,
2540 pub description: String,
2541 pub project_id: ProjectId,
2542 pub project_key: String,
2543 pub project_prefix: String,
2544 pub status: TaskStatus,
2545 pub priority: TaskPriority,
2546 pub created_at: String,
2547 pub updated_at: String,
2548 pub available_at: Option<String>,
2549 pub due_on: Option<String>,
2550 pub metadata: Vec<MetadataValueRecord>,
2551 pub related: Vec<RelatedTaskRecord>,
2552}
2553
2554impl From<Task> for TaskRecord {
2555 fn from(task: Task) -> Self {
2556 Self {
2557 id: task.id,
2558 workspace_id: task.workspace_id,
2559 title: task.title,
2560 description: task.description,
2561 project_id: task.project_id,
2562 project_key: task.project_key,
2563 project_prefix: task.project_prefix,
2564 status: task.status,
2565 priority: task.priority,
2566 created_at: task.created_at,
2567 updated_at: task.updated_at,
2568 available_at: task.available_at,
2569 due_on: task.due_on,
2570 metadata: Vec::new(),
2571 related: Vec::new(),
2572 }
2573 }
2574}
2575
2576impl TaskRecord {
2577 fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
2578 Self::with_metadata_and_related(task, metadata, Vec::new())
2579 }
2580
2581 fn with_metadata_and_related(
2582 task: Task,
2583 metadata: Vec<TaskMetadataValue>,
2584 related: Vec<crate::query::TaskRelatedLink>,
2585 ) -> Self {
2586 let mut record = Self::from(task);
2587 record.metadata = metadata.into_iter().map(Into::into).collect();
2588 record.related = related.into_iter().map(Into::into).collect();
2589 record
2590 }
2591}
2592
2593#[derive(Debug, Clone, PartialEq, Eq)]
2594pub struct TaskUpdateResult {
2595 pub task: TaskRecord,
2596 pub changed: bool,
2597}
2598
2599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2600pub enum ConflictField {
2601 Title,
2602 Description,
2603 Project,
2604 Status,
2605 Priority,
2606 AvailableAt,
2607 DueOn,
2608 Deleted,
2609 IsEpic,
2610}
2611
2612impl ConflictField {
2613 pub const fn as_str(self) -> &'static str {
2614 match self {
2615 Self::Title => "title",
2616 Self::Description => "description",
2617 Self::Project => "project",
2618 Self::Status => "status",
2619 Self::Priority => "priority",
2620 Self::AvailableAt => "available_at",
2621 Self::DueOn => "due_on",
2622 Self::Deleted => "deleted",
2623 Self::IsEpic => "is_epic",
2624 }
2625 }
2626
2627 fn from_task_field(field: TaskField) -> Self {
2628 match field {
2629 TaskField::Title => Self::Title,
2630 TaskField::Description => Self::Description,
2631 TaskField::Project => Self::Project,
2632 TaskField::Status => Self::Status,
2633 TaskField::Priority => Self::Priority,
2634 TaskField::AvailableAt => Self::AvailableAt,
2635 TaskField::DueOn => Self::DueOn,
2636 TaskField::Deleted => Self::Deleted,
2637 TaskField::IsEpic => Self::IsEpic,
2638 }
2639 }
2640}
2641
2642#[derive(Debug, Clone, PartialEq, Eq)]
2643pub struct ConflictSummary {
2644 pub task_id: TaskId,
2645 pub task_title: String,
2646 pub project_key: String,
2647 pub project_prefix: String,
2648 pub field: ConflictField,
2649}
2650
2651#[derive(Debug, Clone, PartialEq, Eq)]
2652pub struct Conflict {
2653 pub task_id: TaskId,
2654 pub field: ConflictField,
2655 pub local_value: String,
2656 pub remote_value: String,
2657 pub variant_a: String,
2658 pub variant_b: String,
2659}
2660
2661#[derive(Debug, Clone, PartialEq, Eq)]
2662pub enum ConflictResolution {
2663 Local,
2664 Remote,
2665 Explicit(String),
2666}
2667
2668fn bounded_conflict_display_value(value: String) -> String {
2669 const MAX_CHARACTERS: usize = 512;
2670 let mut characters = value.chars();
2671 let preview: String = characters.by_ref().take(MAX_CHARACTERS).collect();
2672 if characters.next().is_some() {
2673 format!("{preview}…")
2674 } else {
2675 preview
2676 }
2677}
2678
2679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2680pub enum ErrorCode {
2681 Validation,
2682 NotFound,
2683 OpenConflict,
2684 GenerationConflict,
2685 Database,
2686 Internal,
2687}
2688
2689impl ErrorCode {
2690 pub const fn as_str(self) -> &'static str {
2691 match self {
2692 Self::Validation => "validation",
2693 Self::NotFound => "not_found",
2694 Self::OpenConflict => "open_conflict",
2695 Self::GenerationConflict => "generation_conflict",
2696 Self::Database => "database",
2697 Self::Internal => "internal",
2698 }
2699 }
2700}
2701
2702#[derive(Debug, Clone, PartialEq, Eq)]
2703pub struct Error {
2704 pub code: ErrorCode,
2705 pub message: String,
2706}
2707
2708impl Error {
2709 fn new(code: ErrorCode, message: String) -> Self {
2710 Self { code, message }
2711 }
2712
2713 fn database_open(error: InternalError) -> Self {
2714 Self::new(ErrorCode::Database, error.to_string())
2715 }
2716
2717 fn from_internal(error: InternalError) -> Self {
2718 let code = error
2719 .chain()
2720 .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
2721 .map(|error| match error.kind() {
2722 crate::error::ErrorKind::Validation => ErrorCode::Validation,
2723 crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
2724 crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
2725 crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
2726 })
2727 .unwrap_or_else(|| {
2728 if error
2729 .chain()
2730 .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
2731 .any(|error| matches!(error, sqlx::Error::RowNotFound))
2732 {
2733 ErrorCode::NotFound
2734 } else if error
2735 .chain()
2736 .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
2737 {
2738 ErrorCode::Database
2739 } else {
2740 ErrorCode::Internal
2741 }
2742 });
2743 Self::new(code, error.to_string())
2744 }
2745}
2746
2747impl fmt::Display for Error {
2748 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2749 write!(formatter, "{}: {}", self.code.as_str(), self.message)
2750 }
2751}
2752
2753impl std::error::Error for Error {}
2754
2755#[cfg(test)]
2756mod tests {
2757 use super::*;
2758
2759 #[tokio::test]
2760 async fn ios_capture_receipt_survives_post_commit_read_failure() {
2761 let directory = tempfile::tempdir().unwrap();
2762 let store = Store::open(directory.path().join("capture.sqlite"))
2763 .await
2764 .unwrap();
2765 let workspace = store.resolve_workspace("default").await.unwrap();
2766 store
2767 .create_task(
2768 &workspace.id,
2769 CreateTask {
2770 title: "Seed".into(),
2771 description: String::new(),
2772 project: "ios".into(),
2773 status: TaskStatus::Done,
2774 priority: TaskPriority::None,
2775 metadata: Vec::new(),
2776 available_at: None,
2777 due_on: None,
2778 },
2779 )
2780 .await
2781 .unwrap();
2782 store
2783 .fail_queue_reads
2784 .store(true, std::sync::atomic::Ordering::SeqCst);
2785 let result = store
2786 .capture_ios_queue_task(
2787 &workspace.id,
2788 IosTaskCapture {
2789 title: "Captured".into(),
2790 description: String::new(),
2791 project: Some("ios".into()),
2792 priority: TaskPriority::None,
2793 due_on: None,
2794 labels: Vec::new(),
2795 },
2796 )
2797 .await;
2798 let tasks = store.list_tasks(&workspace.id).await.unwrap();
2799 assert_eq!(
2800 tasks.iter().filter(|task| task.title == "Captured").count(),
2801 1
2802 );
2803 assert!(
2804 result.is_ok(),
2805 "committed capture lost its receipt: {result:?}"
2806 );
2807 let captured = result.unwrap();
2808 assert!(captured.state.is_none());
2809 assert_eq!(captured.display_ref, captured.task_id.to_string());
2810 assert!(!captured.undo_token.is_empty());
2811 let mutation_target = store
2812 .capture_ios_queue_task(
2813 &workspace.id,
2814 IosTaskCapture {
2815 title: "Mutation target".into(),
2816 description: String::new(),
2817 project: Some("ios".into()),
2818 priority: TaskPriority::None,
2819 due_on: None,
2820 labels: Vec::new(),
2821 },
2822 )
2823 .await
2824 .unwrap();
2825 let changed = store
2827 .mutate_ios_queue_task(
2828 &workspace.id,
2829 &mutation_target.task_id,
2830 IosQueueMutation {
2831 kind: IosQueueMutationKind::SetPriority,
2832 priority: Some(TaskPriority::High),
2833 local_date: None,
2834 time_zone: None,
2835 },
2836 )
2837 .await
2838 .unwrap();
2839 assert_eq!(changed.task_id, mutation_target.task_id);
2840 assert!(changed.state.is_none());
2841 assert_eq!(
2842 store
2843 .ios_task_detail(&workspace.id, &mutation_target.task_id)
2844 .await
2845 .unwrap()
2846 .priority,
2847 TaskPriority::High
2848 );
2849 assert!(
2850 store
2851 .undo_ios_queue_mutation(&changed.undo_token)
2852 .await
2853 .unwrap()
2854 .is_none()
2855 );
2856 assert_eq!(
2857 store
2858 .ios_task_detail(&workspace.id, &mutation_target.task_id)
2859 .await
2860 .unwrap()
2861 .priority,
2862 TaskPriority::None
2863 );
2864 assert!(
2865 store
2866 .undo_ios_queue_capture(&captured.undo_token)
2867 .await
2868 .unwrap()
2869 .is_none()
2870 );
2871 store
2872 .fail_queue_reads
2873 .store(false, std::sync::atomic::Ordering::SeqCst);
2874 let state = store.ios_queue_state().await.unwrap();
2875 assert!(
2876 state
2877 .queue
2878 .tasks
2879 .iter()
2880 .all(|task| task.id != captured.task_id)
2881 );
2882 }
2883
2884 #[test]
2885 fn error_codes_are_stable_and_internal_errors_are_distinct() {
2886 assert_eq!(ErrorCode::Validation.as_str(), "validation");
2887 assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
2888 assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
2889 assert_eq!(
2890 ErrorCode::GenerationConflict.as_str(),
2891 "generation_conflict"
2892 );
2893 assert_eq!(ErrorCode::Database.as_str(), "database");
2894 assert_eq!(ErrorCode::Internal.as_str(), "internal");
2895 assert_eq!(
2896 Error::from_internal(
2897 crate::error::CoreError::generation_conflict(
2898 "error recurrence-generation-conflict slot=2026-08-01 field=task"
2899 )
2900 .into()
2901 )
2902 .code,
2903 ErrorCode::GenerationConflict
2904 );
2905 assert_eq!(
2906 Error::from_internal(
2907 crate::error::CoreError::not_found("error recurrence-series-not-found").into()
2908 )
2909 .code,
2910 ErrorCode::NotFound
2911 );
2912 assert_eq!(
2913 Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
2914 ErrorCode::Internal
2915 );
2916 assert_eq!(
2917 Error::from_internal(sqlx::Error::PoolClosed.into()).code,
2918 ErrorCode::Database
2919 );
2920 }
2921}