diff options
Diffstat (limited to 'src/infrastructure')
| -rw-r--r-- | src/infrastructure/mod.rs | 1 | ||||
| -rw-r--r-- | src/infrastructure/persistence/calendar_repository.rs | 119 | ||||
| -rw-r--r-- | src/infrastructure/persistence/event_repository.rs | 168 | ||||
| -rw-r--r-- | src/infrastructure/persistence/mappers.rs | 278 | ||||
| -rw-r--r-- | src/infrastructure/persistence/mod.rs | 7 | ||||
| -rw-r--r-- | src/infrastructure/persistence/models.rs | 53 | ||||
| -rw-r--r-- | src/infrastructure/persistence/recurring_event_repository.rs | 174 |
7 files changed, 800 insertions, 0 deletions
diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..c93e4ff --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1 @@ +pub mod persistence; diff --git a/src/infrastructure/persistence/calendar_repository.rs b/src/infrastructure/persistence/calendar_repository.rs new file mode 100644 index 0000000..d0b758d --- /dev/null +++ b/src/infrastructure/persistence/calendar_repository.rs @@ -0,0 +1,119 @@ +use async_trait::async_trait; +use sqlx::SqlitePool; +use crate::{ + domain::{ + calendar::Calendar, + repository::{CalendarRepository, RepositoryError, Result}, + value_objects::CalendarId, + }, + infrastructure::persistence::models::CalendarModel +}; +use super::mappers::CalendarMapper; + +pub struct SqliteCalendarRepository { + pool: SqlitePool, +} + +impl SqliteCalendarRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl CalendarRepository for SqliteCalendarRepository { + async fn save(&self, calendar: &Calendar) -> Result<()> { + let model = CalendarMapper::to_model(calendar); + + sqlx::query!( + r#" + INSERT INTO calendars ( + id, name, description, is_archived, + created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + is_archived = excluded.is_archived, + updated_at = excluded.updated_at + "#, + model.id, + model.name, + model.description, + model.is_archived, + model.created_at, + model.updated_at, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + Ok(()) + } + + async fn find_by_id(&self, id: &CalendarId) -> Result<Option<Calendar>> { + let id_str = id.to_string(); + + let model = sqlx::query_as::<_, CalendarModel>( + r#" + SELECT id, name, description, is_archived, created_at, updated_at + FROM calendars + WHERE id = ?1 + "# + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + match model { + Some(m) => { + let calendar = CalendarMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))?; + Ok(Some(calendar)) + } + None => Ok(None), + } + } + + async fn find_all_active(&self) -> Result<Vec<Calendar>> { + let models = sqlx::query_as::<_, CalendarModel>( + r#" + SELECT id, name, description, is_archived, created_at, updated_at + FROM calendars + WHERE is_archived = 0 + ORDER BY name + "# + ) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + models + .into_iter() + .map(|m| CalendarMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))) + .collect() + } + + async fn delete(&self, id: &CalendarId) -> Result<()> { + let id_str = id.to_string(); + + let result = sqlx::query!( + r#" + DELETE FROM calendars WHERE id = ?1 + "#, + id_str, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} diff --git a/src/infrastructure/persistence/event_repository.rs b/src/infrastructure/persistence/event_repository.rs new file mode 100644 index 0000000..cd83e99 --- /dev/null +++ b/src/infrastructure/persistence/event_repository.rs @@ -0,0 +1,168 @@ +use async_trait::async_trait; +use sqlx::SqlitePool; +use crate::domain::{ + event::Event, + repository::{EventRepository, RepositoryError, Result}, + value_objects::{CalendarId, EventId, TimeRange}, +}; +use super::{models::EventModel, mappers::EventMapper}; + +pub struct SqliteEventRepository { + pool: SqlitePool, +} + +impl SqliteEventRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl EventRepository for SqliteEventRepository { + async fn save(&self, event: &Event) -> Result<()> { + let model = EventMapper::to_model(event); + + sqlx::query!( + r#" + INSERT INTO events ( + id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + description = excluded.description, + starts_at = excluded.starts_at, + ends_at = excluded.ends_at, + color = excluded.color, + is_all_day = excluded.is_all_day, + is_cancelled = excluded.is_cancelled, + updated_at = excluded.updated_at + "#, + model.id, + model.calendar_id, + model.title, + model.description, + model.starts_at, + model.ends_at, + model.color, + model.is_all_day, + model.is_cancelled, + model.created_at, + model.updated_at, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + Ok(()) + } + + async fn find_by_id(&self, id: &EventId) -> Result<Option<Event>> { + let id_str = id.to_string(); + + let model = sqlx::query_as::<_, EventModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + FROM events + WHERE id = ?1 + "# + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + match model { + Some(m) => { + let event = EventMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))?; + Ok(Some(event)) + } + None => Ok(None), + } + } + + async fn find_by_calendar( + &self, + calendar_id: &CalendarId + ) -> Result<Vec<Event>> { + let calendar_id_str = calendar_id.to_string(); + + let models = sqlx::query_as::<_, EventModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + FROM events + WHERE calendar_id = ?1 + ORDER BY starts_at + "# + ) + .bind(&calendar_id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + models + .into_iter() + .map(|m| EventMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))) + .collect() + } + + async fn find_in_range( + &self, + calendar_id: &CalendarId, + range: &TimeRange, + ) -> Result<Vec<Event>> { + let calendar_id_str = calendar_id.to_string(); + let range_start = range.starts_at().to_rfc3339(); + let range_end = range.ends_at().to_rfc3339(); + + let models = sqlx::query_as::<_, EventModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + FROM events + WHERE calendar_id = ?1 + AND is_cancelled = 0 + AND starts_at < ?3 + AND ends_at > ?2 + ORDER BY starts_at + "# + ) + .bind(&calendar_id_str) + .bind(&range_start) + .bind(&range_end) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + models + .into_iter() + .map(|m| EventMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))) + .collect() + } + + async fn delete(&self, id: &EventId) -> Result<()> { + let id_str = id.to_string(); + + let result = sqlx::query!( + r#" + DELETE FROM events WHERE id = ?1 + "#, + id_str, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} diff --git a/src/infrastructure/persistence/mappers.rs b/src/infrastructure/persistence/mappers.rs new file mode 100644 index 0000000..4260038 --- /dev/null +++ b/src/infrastructure/persistence/mappers.rs @@ -0,0 +1,278 @@ +use chrono::{DateTime, Utc}; +use crate::domain::{ + calendar::Calendar, + event::Event, + recurrence::{ + ExceptionModification, + RecurrenceException, + RecurrenceRule, + RecurringEvent, + }, + value_objects::{ + CalendarId, + EventColor, + EventId, + Frequency, + TimeRange, + }, +}; +use super::models::{ + CalendarModel, + EventModel, + RecurrenceModel, + RecurrenceExceptionModel +}; +use std::{collections::HashMap, str::FromStr}; + +pub struct CalendarMapper; + +impl CalendarMapper { + pub fn to_domain(model: CalendarModel) -> Result<Calendar, String> { + let id = CalendarId::from_str(&model.id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + Ok(Calendar::with_id( + id, + model.name, + model.description, + model.is_archived != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(calendar: &Calendar) -> CalendarModel { + CalendarModel { + id: calendar.id().to_string(), + name: calendar.name().to_string(), + description: calendar.description().clone(), + is_archived: if *calendar.is_archived() { 1 } else { 0 }, + created_at: calendar.created_at().to_rfc3339(), + updated_at: calendar.updated_at().to_rfc3339(), + } + } +} + +pub struct EventMapper; + +impl EventMapper { + pub fn to_domain(model: EventModel) -> Result<Event, String> { + let id = EventId::from_str(&model.id) + .map_err(|e| format!("Invalid event ID: {}", e))?; + + let calendar_id = CalendarId::from_str(&model.calendar_id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let starts_at = DateTime::parse_from_rfc3339(&model.starts_at) + .map_err(|e| format!("Invalid starts_at: {}", e))? + .with_timezone(&Utc); + + let ends_at = DateTime::parse_from_rfc3339(&model.ends_at) + .map_err(|e| format!("Invalid ends_at: {}", e))? + .with_timezone(&Utc); + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + let time_range = TimeRange::new(starts_at, ends_at) + .map_err(|e| e.to_string())?; + + let color = EventColor::from(model.color as u8); + + Ok(Event::with_id( + id, + calendar_id, + model.title, + model.description, + time_range, + color, + model.is_all_day != 0, + model.is_cancelled != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(event: &Event) -> EventModel { + EventModel { + id: event.id().to_string(), + calendar_id: event.calendar_id().to_string(), + title: event.title().to_string(), + description: event.description().clone(), + starts_at: event.time_range().starts_at().to_rfc3339(), + ends_at: event.time_range().ends_at().to_rfc3339(), + color: u8::from(*event.color()) as i64, + is_all_day: if *event.is_all_day() { 1 } else { 0 }, + is_cancelled: if *event.is_cancelled() { 1 } else { 0 }, + created_at: event.created_at().to_rfc3339(), + updated_at: event.updated_at().to_rfc3339(), + } + } +} + +pub struct RecurrenceMapper; + +impl RecurrenceMapper { + pub fn to_domain( + model: RecurrenceModel, + exceptions: Vec<RecurrenceExceptionModel>, + ) -> Result<RecurringEvent, String> { + let id = EventId::from_str(&model.id) + .map_err(|e| format!("Invalid event ID: {}", e))?; + + let calendar_id = CalendarId::from_str(&model.calendar_id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let starts_at = DateTime::parse_from_rfc3339(&model.starts_at) + .map_err(|e| format!("Invalid starts_at: {}", e))? + .with_timezone(&Utc); + + let ends_at = DateTime::parse_from_rfc3339(&model.ends_at) + .map_err(|e| format!("Invalid ends_at: {}", e))? + .with_timezone(&Utc); + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + let base_time_range = TimeRange::new(starts_at, ends_at) + .map_err(|e| e.to_string())?; + + let color = EventColor::from(model.color as u8); + + let frequency = Frequency::from_str(&model.frequency) + .map_err(|e| e.to_string())?; + + let until = if let Some(until_str) = model.until { + Some(DateTime::parse_from_rfc3339(&until_str) + .map_err(|e| format!("Invalid until: {}", e))? + .with_timezone(&Utc)) + } else { + None + }; + + let recurrence_rule = RecurrenceRule::new( + frequency, + model.interval as u32, + until, + ).map_err(|e| e.to_string())?; + + let exception_map = exceptions + .into_iter() + .map(Self::exception_to_domain) + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .map(|ex| (*ex.original_starts_at(), ex)) + .collect::<HashMap<DateTime<Utc>, RecurrenceException>>(); + + Ok(RecurringEvent::with_id( + id, + calendar_id, + model.title, + model.description, + base_time_range, + recurrence_rule, + exception_map, + color, + model.is_all_day != 0, + model.is_cancelled != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(event: &RecurringEvent) -> RecurrenceModel { + RecurrenceModel { + id: event.id().to_string(), + calendar_id: event.calendar_id().to_string(), + title: event.title().to_string(), + description: event.description().clone(), + starts_at: event.time_range().starts_at().to_rfc3339(), + ends_at: event.time_range().ends_at().to_rfc3339(), + frequency: event.rule().frequency().to_string(), + interval: *event.rule().interval() as i64, + until: event.rule().until().map(|dt| dt.to_rfc3339()), + color: u8::from(*event.color()) as i64, + is_all_day: if *event.is_all_day() { 1 } else { 0 }, + is_cancelled: if *event.is_cancelled() { 1 } else { 0 }, + created_at: event.created_at().to_rfc3339(), + updated_at: event.updated_at().to_rfc3339(), + } + } + + fn exception_to_domain( + model: RecurrenceExceptionModel, + ) -> Result<RecurrenceException, String> { + let original_starts_at = DateTime::parse_from_rfc3339( + &model.original_starts_at + ) + .map_err(|e| format!("Invalid original_starts_at: {}", e))? + .with_timezone(&Utc); + + if model.is_cancelled != 0 { + Ok(RecurrenceException::cancelled(original_starts_at)) + } else if let (Some(new_starts), Some(new_ends)) = + (model.new_starts_at, model.new_ends_at) { + let starts = DateTime::parse_from_rfc3339(&new_starts) + .map_err(|e| format!("Invalid new_starts_at: {}", e))? + .with_timezone(&Utc); + + let ends = DateTime::parse_from_rfc3339(&new_ends) + .map_err(|e| format!("Invalid new_ends_at: {}", e))? + .with_timezone(&Utc); + + let new_time_range = TimeRange::new(starts, ends) + .map_err(|e| e.to_string())?; + + Ok(RecurrenceException::rescheduled( + original_starts_at, + new_time_range, + )) + } else { + Err( + "Exception must be either cancelled or have new time range" + .to_string() + ) + } + } + + pub fn exception_to_model( + exception: &RecurrenceException, + recurrence_id: &EventId, + ) -> RecurrenceExceptionModel { + let (new_starts_at, new_ends_at, is_cancelled) = + match exception.modification() { + ExceptionModification::Cancelled => (None, None, 1), + ExceptionModification::Rescheduled { new_time_range } => ( + Some(new_time_range.starts_at().to_rfc3339()), + Some(new_time_range.ends_at().to_rfc3339()), + 0, + ), + }; + + RecurrenceExceptionModel { + recurrence_id: recurrence_id.to_string(), + original_starts_at: exception.original_starts_at().to_rfc3339(), + new_starts_at, + new_ends_at, + is_cancelled, + } + } +} diff --git a/src/infrastructure/persistence/mod.rs b/src/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..99e0215 --- /dev/null +++ b/src/infrastructure/persistence/mod.rs @@ -0,0 +1,7 @@ +pub mod models; +pub mod mappers; +pub mod calendar_repository; +pub mod event_repository; +pub mod recurring_event_repository; + +pub use calendar_repository::SqliteCalendarRepository; diff --git a/src/infrastructure/persistence/models.rs b/src/infrastructure/persistence/models.rs new file mode 100644 index 0000000..dc39e94 --- /dev/null +++ b/src/infrastructure/persistence/models.rs @@ -0,0 +1,53 @@ +use sqlx::FromRow; + +#[derive(Debug, FromRow)] +pub struct CalendarModel { + pub id: String, + pub name: String, + pub description: Option<String>, + pub is_archived: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, FromRow)] +pub struct EventModel { + pub id: String, + pub calendar_id: String, + pub title: String, + pub description: Option<String>, + pub starts_at: String, + pub ends_at: String, + pub color: i64, + pub is_all_day: i64, + pub is_cancelled: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, FromRow)] +pub struct RecurrenceModel { + pub id: String, + pub calendar_id: String, + pub title: String, + pub description: Option<String>, + pub starts_at: String, + pub ends_at: String, + pub frequency: String, + pub interval: i64, + pub until: Option<String>, + pub color: i64, + pub is_all_day: i64, + pub is_cancelled: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, FromRow)] +pub struct RecurrenceExceptionModel { + pub recurrence_id: String, + pub original_starts_at: String, + pub new_starts_at: Option<String>, + pub new_ends_at: Option<String>, + pub is_cancelled: i64, +} diff --git a/src/infrastructure/persistence/recurring_event_repository.rs b/src/infrastructure/persistence/recurring_event_repository.rs new file mode 100644 index 0000000..af5753a --- /dev/null +++ b/src/infrastructure/persistence/recurring_event_repository.rs @@ -0,0 +1,174 @@ +use async_trait::async_trait; +use sqlx::SqlitePool; +use crate::domain::{ + recurrence::RecurringEvent, + repository::{RecurringEventRepository, RepositoryError, Result}, + value_objects::CalendarId, +}; +use super::{ + models::{RecurrenceModel, RecurrenceExceptionModel}, + mappers::RecurrenceMapper, +}; + +pub struct SqliteRecurringEventRepository { + pool: SqlitePool, +} + +impl SqliteRecurringEventRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl RecurringEventRepository for SqliteRecurringEventRepository { + async fn save(&self, event: &RecurringEvent) -> Result<()> { + let mut tx = self.pool.begin() + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + let model = RecurrenceMapper::to_model(event); + + sqlx::query!( + r#" + INSERT INTO recurrences ( + id, calendar_id, title, description, starts_at, ends_at, + frequency, interval, until, color, is_all_day, is_cancelled, + created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + description = excluded.description, + starts_at = excluded.starts_at, + ends_at = excluded.ends_at, + frequency = excluded.frequency, + interval = excluded.interval, + until = excluded.until, + color = excluded.color, + is_all_day = excluded.is_all_day, + is_cancelled = excluded.is_cancelled, + updated_at = excluded.updated_at + "#, + model.id, + model.calendar_id, + model.title, + model.description, + model.starts_at, + model.ends_at, + model.frequency, + model.interval, + model.until, + model.color, + model.is_all_day, + model.is_cancelled, + model.created_at, + model.updated_at, + ) + .execute(&mut *tx) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + sqlx::query!( + r#" + DELETE FROM recurrence_exceptions WHERE recurrence_id = ?1 + "#, + model.id, + ) + .execute(&mut *tx) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + for exception in event.exceptions().values() { + let ex_model = RecurrenceMapper::exception_to_model( + exception, + event.id(), + ); + + sqlx::query!( + r#" + INSERT INTO recurrence_exceptions ( + recurrence_id, original_starts_at, new_starts_at, + new_ends_at, is_cancelled + ) + VALUES (?1, ?2, ?3, ?4, ?5) + "#, + ex_model.recurrence_id, + ex_model.original_starts_at, + ex_model.new_starts_at, + ex_model.new_ends_at, + ex_model.is_cancelled, + ) + .execute(&mut *tx) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + } + + tx.commit() + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + Ok(()) + } + + async fn find_by_calendar(&self, calendar_id: &CalendarId) -> Result<Vec<RecurringEvent>> { + let calendar_id_str = calendar_id.to_string(); + + let models = sqlx::query_as::<_, RecurrenceModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + frequency, interval, until, color, is_all_day, is_cancelled, + created_at, updated_at + FROM recurrences + WHERE calendar_id = ?1 + ORDER BY starts_at + "# + ) + .bind(&calendar_id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + let mut result = Vec::new(); + + for model in models { + let exceptions = sqlx::query_as::<_, RecurrenceExceptionModel>( + r#" + SELECT id, recurrence_id, original_starts_at, new_starts_at, + new_ends_at, is_cancelled + FROM recurrence_exceptions + WHERE recurrence_id = ?1 + "# + ) + .bind(&model.id) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + let event = RecurrenceMapper::to_domain(model, exceptions) + .map_err(|e| RepositoryError::DatabaseError(e))?; + + result.push(event); + } + + Ok(result) + } + + async fn delete(&self, id: &str) -> Result<()> { + let result = sqlx::query!( + r#" + DELETE FROM recurrences WHERE id = ?1 + "#, + id, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} |