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