max / goingson
9 files changed,
+277 insertions,
-170 deletions
| @@ -628,7 +628,19 @@ pub struct HighUrgencyTask { | |||
| 628 | 628 | #[async_trait] | |
| 629 | 629 | pub trait StatsRepository: Send + Sync { | |
| 630 | 630 | /// Computes aggregated dashboard statistics for a user. | |
| 631 | - | async fn get_dashboard_stats(&self, user_id: UserId) -> Result<DashboardStats>; | |
| 631 | + | /// | |
| 632 | + | /// Calendar-day windows (`today_start`, `tomorrow_start`, `week_end`) are | |
| 633 | + | /// passed in as UTC instants derived from the user's *local* day, so "due | |
| 634 | + | /// today/this week" respect the user's timezone rather than UTC midnights. | |
| 635 | + | /// `now` is the reference instant for overdue/upcoming comparisons. | |
| 636 | + | async fn get_dashboard_stats( | |
| 637 | + | &self, | |
| 638 | + | user_id: UserId, | |
| 639 | + | now: DateTime<Utc>, | |
| 640 | + | today_start: DateTime<Utc>, | |
| 641 | + | tomorrow_start: DateTime<Utc>, | |
| 642 | + | week_end: DateTime<Utc>, | |
| 643 | + | ) -> Result<DashboardStats>; | |
| 632 | 644 | } | |
| 633 | 645 | ||
| 634 | 646 | /// Type of item in search results. |
| @@ -146,7 +146,13 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 146 | 146 | let user_id_str = user_id.to_string(); | |
| 147 | 147 | let now = format_datetime(&Utc::now()); | |
| 148 | 148 | ||
| 149 | - | // Check if a goal exists at this position for this month | |
| 149 | + | // monthly_goals has no unique key on (user_id, month, position) and one | |
| 150 | + | // can't be added safely (it would make two devices' position-1 goals | |
| 151 | + | // collide on sync apply). So make the get-then-write atomic with a | |
| 152 | + | // transaction instead of an ON CONFLICT upsert — closes the race that | |
| 153 | + | // could double-insert a goal at the same position. | |
| 154 | + | let mut tx = self.pool.begin().await.map_err(CoreError::database)?; | |
| 155 | + | ||
| 150 | 156 | let existing: Option<MonthlyGoalRow> = sqlx::query_as( | |
| 151 | 157 | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 152 | 158 | FROM monthly_goals | |
| @@ -155,11 +161,11 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 155 | 161 | .bind(&user_id_str) | |
| 156 | 162 | .bind(month) | |
| 157 | 163 | .bind(position) | |
| 158 | - | .fetch_optional(&self.pool) | |
| 164 | + | .fetch_optional(&mut *tx) | |
| 159 | 165 | .await | |
| 160 | 166 | .map_err(CoreError::database)?; | |
| 161 | 167 | ||
| 162 | - | if let Some(existing) = existing { | |
| 168 | + | let goal = if let Some(existing) = existing { | |
| 163 | 169 | let id = existing.id.clone(); | |
| 164 | 170 | sqlx::query( | |
| 165 | 171 | "UPDATE monthly_goals SET text = ?, updated_at = ? WHERE id = ?" | |
| @@ -167,14 +173,14 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 167 | 173 | .bind(text) | |
| 168 | 174 | .bind(&now) | |
| 169 | 175 | .bind(&id) | |
| 170 | - | .execute(&self.pool) | |
| 176 | + | .execute(&mut *tx) | |
| 171 | 177 | .await | |
| 172 | 178 | .map_err(CoreError::database)?; | |
| 173 | 179 | ||
| 174 | 180 | let mut goal = MonthlyGoal::try_from(existing)?; | |
| 175 | 181 | goal.text = text.to_string(); | |
| 176 | 182 | goal.updated_at = Utc::now(); | |
| 177 | - | Ok(goal) | |
| 183 | + | goal | |
| 178 | 184 | } else { | |
| 179 | 185 | let id = MonthlyGoalId::new(); | |
| 180 | 186 | sqlx::query( | |
| @@ -188,12 +194,12 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 188 | 194 | .bind(position) | |
| 189 | 195 | .bind(&now) | |
| 190 | 196 | .bind(&now) | |
| 191 | - | .execute(&self.pool) | |
| 197 | + | .execute(&mut *tx) | |
| 192 | 198 | .await | |
| 193 | 199 | .map_err(CoreError::database)?; | |
| 194 | 200 | ||
| 195 | 201 | let now_dt = Utc::now(); | |
| 196 | - | Ok(MonthlyGoal { | |
| 202 | + | MonthlyGoal { | |
| 197 | 203 | id, | |
| 198 | 204 | user_id, | |
| 199 | 205 | month: month.to_string(), | |
| @@ -202,8 +208,11 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 202 | 208 | position, | |
| 203 | 209 | created_at: now_dt, | |
| 204 | 210 | updated_at: now_dt, | |
| 205 | - | }) | |
| 206 | - | } | |
| 211 | + | } | |
| 212 | + | }; | |
| 213 | + | ||
| 214 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 215 | + | Ok(goal) | |
| 207 | 216 | } | |
| 208 | 217 | ||
| 209 | 218 | #[tracing::instrument(skip_all)] | |
| @@ -275,48 +284,28 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 275 | 284 | let now = Utc::now(); | |
| 276 | 285 | let now_str = format_datetime(&now); | |
| 277 | 286 | ||
| 278 | - | let existing = self.get_reflection(user_id, month).await?; | |
| 279 | - | ||
| 280 | - | let id = if let Some(existing) = existing { | |
| 281 | - | sqlx::query( | |
| 282 | - | "UPDATE monthly_reflections SET highlight_text = ?, change_text = ?, completed_at = ? | |
| 283 | - | WHERE id = ?" | |
| 284 | - | ) | |
| 285 | - | .bind(highlight) | |
| 286 | - | .bind(change) | |
| 287 | - | .bind(&now_str) | |
| 288 | - | .bind(existing.id.to_string()) | |
| 289 | - | .execute(&self.pool) | |
| 290 | - | .await | |
| 291 | - | .map_err(CoreError::database)?; | |
| 292 | - | ||
| 293 | - | existing.id | |
| 294 | - | } else { | |
| 295 | - | let id = MonthlyReflectionId::new(); | |
| 296 | - | sqlx::query( | |
| 297 | - | "INSERT INTO monthly_reflections (id, user_id, month, highlight_text, change_text, completed_at) | |
| 298 | - | VALUES (?, ?, ?, ?, ?, ?)" | |
| 299 | - | ) | |
| 300 | - | .bind(id.to_string()) | |
| 301 | - | .bind(&user_id_str) | |
| 302 | - | .bind(month) | |
| 303 | - | .bind(highlight) | |
| 304 | - | .bind(change) | |
| 305 | - | .bind(&now_str) | |
| 306 | - | .execute(&self.pool) | |
| 307 | - | .await | |
| 308 | - | .map_err(CoreError::database)?; | |
| 309 | - | ||
| 310 | - | id | |
| 311 | - | }; | |
| 287 | + | // Atomic upsert on the (user_id, month) unique key — no get-then-write | |
| 288 | + | // race. | |
| 289 | + | sqlx::query( | |
| 290 | + | "INSERT INTO monthly_reflections (id, user_id, month, highlight_text, change_text, completed_at) | |
| 291 | + | VALUES (?, ?, ?, ?, ?, ?) | |
| 292 | + | ON CONFLICT(user_id, month) DO UPDATE SET | |
| 293 | + | highlight_text = excluded.highlight_text, | |
| 294 | + | change_text = excluded.change_text, | |
| 295 | + | completed_at = excluded.completed_at", | |
| 296 | + | ) | |
| 297 | + | .bind(MonthlyReflectionId::new().to_string()) | |
| 298 | + | .bind(&user_id_str) | |
| 299 | + | .bind(month) | |
| 300 | + | .bind(highlight) | |
| 301 | + | .bind(change) | |
| 302 | + | .bind(&now_str) | |
| 303 | + | .execute(&self.pool) | |
| 304 | + | .await | |
| 305 | + | .map_err(CoreError::database)?; | |
| 312 | 306 | ||
| 313 | - | Ok(MonthlyReflection { | |
| 314 | - | id, | |
| 315 | - | user_id, | |
| 316 | - | month: month.to_string(), | |
| 317 | - | highlight_text: highlight.to_string(), | |
| 318 | - | change_text: change.to_string(), | |
| 319 | - | completed_at: now, | |
| 320 | - | }) | |
| 307 | + | self.get_reflection(user_id, month) | |
| 308 | + | .await? | |
| 309 | + | .ok_or_else(|| CoreError::database_msg("monthly reflection vanished after upsert")) | |
| 321 | 310 | } | |
| 322 | 311 | } |
| @@ -7,10 +7,11 @@ | |||
| 7 | 7 | //! - High-urgency task list | |
| 8 | 8 | ||
| 9 | 9 | use async_trait::async_trait; | |
| 10 | + | use chrono::{DateTime, Duration, Utc}; | |
| 10 | 11 | use sqlx::SqlitePool; | |
| 11 | 12 | use goingson_core::{CoreError, DashboardStats, HighUrgencyTask, Result, StatsRepository, UserId}; | |
| 12 | 13 | ||
| 13 | - | use crate::utils::parse_datetime; | |
| 14 | + | use crate::utils::{format_datetime, parse_datetime}; | |
| 14 | 15 | ||
| 15 | 16 | /// SQLite-backed implementation of [`StatsRepository`]. | |
| 16 | 17 | /// | |
| @@ -27,10 +28,25 @@ impl SqliteStatsRepository { | |||
| 27 | 28 | #[async_trait] | |
| 28 | 29 | impl StatsRepository for SqliteStatsRepository { | |
| 29 | 30 | #[tracing::instrument(skip_all)] | |
| 30 | - | async fn get_dashboard_stats(&self, user_id: UserId) -> Result<DashboardStats> { | |
| 31 | + | async fn get_dashboard_stats( | |
| 32 | + | &self, | |
| 33 | + | user_id: UserId, | |
| 34 | + | now: DateTime<Utc>, | |
| 35 | + | today_start: DateTime<Utc>, | |
| 36 | + | tomorrow_start: DateTime<Utc>, | |
| 37 | + | week_end: DateTime<Utc>, | |
| 38 | + | ) -> Result<DashboardStats> { | |
| 31 | 39 | let user_id_str = user_id.to_string(); | |
| 40 | + | let now_str = format_datetime(&now); | |
| 41 | + | let today_start_str = format_datetime(&today_start); | |
| 42 | + | let tomorrow_start_str = format_datetime(&tomorrow_start); | |
| 43 | + | let week_end_str = format_datetime(&week_end); | |
| 44 | + | let events_end_str = format_datetime(&(now + Duration::days(7))); | |
| 32 | 45 | ||
| 33 | 46 | // Batch all 6 scalar counts into a single query using subqueries. | |
| 47 | + | // Day windows are bound UTC instants of the user's local day; all | |
| 48 | + | // predicates compare the bare indexed column (no date()/datetime() | |
| 49 | + | // wrapper) so they stay sargable on idx_tasks_due / start_time. | |
| 34 | 50 | #[derive(sqlx::FromRow)] | |
| 35 | 51 | struct StatsRow { | |
| 36 | 52 | tasks_due_today: i64, | |
| @@ -45,22 +61,25 @@ impl StatsRepository for SqliteStatsRepository { | |||
| 45 | 61 | "SELECT \ | |
| 46 | 62 | (SELECT COUNT(*) FROM tasks WHERE user_id = ?1 \ | |
| 47 | 63 | AND status NOT IN ('Completed', 'Deleted') \ | |
| 48 | - | AND due IS NOT NULL AND date(due) = date('now')) AS tasks_due_today, \ | |
| 64 | + | AND due IS NOT NULL AND due >= ?3 AND due < ?4) AS tasks_due_today, \ | |
| 49 | 65 | (SELECT COUNT(*) FROM tasks WHERE user_id = ?1 \ | |
| 50 | 66 | AND status NOT IN ('Completed', 'Deleted') \ | |
| 51 | - | AND due IS NOT NULL AND date(due) >= date('now') \ | |
| 52 | - | AND date(due) <= date('now', '+7 days')) AS tasks_due_this_week, \ | |
| 67 | + | AND due IS NOT NULL AND due >= ?3 AND due < ?5) AS tasks_due_this_week, \ | |
| 53 | 68 | (SELECT COUNT(*) FROM tasks WHERE user_id = ?1 \ | |
| 54 | 69 | AND status NOT IN ('Completed', 'Deleted') \ | |
| 55 | - | AND due IS NOT NULL AND datetime(due) < datetime('now')) AS overdue_count, \ | |
| 70 | + | AND due IS NOT NULL AND due < ?2) AS overdue_count, \ | |
| 56 | 71 | (SELECT COUNT(*) FROM emails WHERE user_id = ?1 \ | |
| 57 | 72 | AND is_read = 0) AS unread_emails, \ | |
| 58 | 73 | (SELECT COUNT(*) FROM events WHERE user_id = ?1 \ | |
| 59 | - | AND datetime(start_time) >= datetime('now') \ | |
| 60 | - | AND datetime(start_time) <= datetime('now', '+7 days')) AS upcoming_events, \ | |
| 74 | + | AND start_time >= ?2 AND start_time <= ?6) AS upcoming_events, \ | |
| 61 | 75 | (SELECT COUNT(*) FROM projects WHERE user_id = ?1 \ | |
| 62 | 76 | AND status = 'Active') AS active_projects") | |
| 63 | - | .bind(&user_id_str) | |
| 77 | + | .bind(&user_id_str) // ?1 | |
| 78 | + | .bind(&now_str) // ?2 | |
| 79 | + | .bind(&today_start_str) // ?3 | |
| 80 | + | .bind(&tomorrow_start_str) // ?4 | |
| 81 | + | .bind(&week_end_str) // ?5 | |
| 82 | + | .bind(&events_end_str) // ?6 | |
| 64 | 83 | .fetch_one(&self.pool).await.map_err(CoreError::database)?; | |
| 65 | 84 | ||
| 66 | 85 | // High-urgency tasks returns rows, so it remains a separate query. |
| @@ -149,13 +149,15 @@ pub(crate) async fn toggle_subtask( | |||
| 149 | 149 | subtask_id: SubtaskId, | |
| 150 | 150 | user_id: UserId, | |
| 151 | 151 | ) -> Result<Option<Subtask>> { | |
| 152 | - | // First check if subtask exists and belongs to user's task | |
| 152 | + | // Flip atomically (1 - is_completed) and read back the new row in one | |
| 153 | + | // statement, scoped to the user's own tasks. Avoids the read-then-write | |
| 154 | + | // race where two concurrent toggles both read the old value and cancel out. | |
| 155 | + | // No matching row (missing or not owned) yields None. | |
| 153 | 156 | let row = sqlx::query_as::<_, SubtaskRow>( | |
| 154 | 157 | r#" | |
| 155 | - | SELECT s.id, s.task_id, s.text, s.linked_task_id, s.is_completed, s.position | |
| 156 | - | FROM subtasks s | |
| 157 | - | JOIN tasks t ON s.task_id = t.id | |
| 158 | - | WHERE s.id = ? AND t.user_id = ? | |
| 158 | + | UPDATE subtasks SET is_completed = 1 - is_completed | |
| 159 | + | WHERE id = ? AND task_id IN (SELECT id FROM tasks WHERE user_id = ?) | |
| 160 | + | RETURNING id, task_id, text, linked_task_id, is_completed, position | |
| 159 | 161 | "# | |
| 160 | 162 | ) | |
| 161 | 163 | .bind(subtask_id.to_string()) | |
| @@ -168,21 +170,12 @@ pub(crate) async fn toggle_subtask( | |||
| 168 | 170 | return Ok(None); | |
| 169 | 171 | }; | |
| 170 | 172 | ||
| 171 | - | let new_completed = if subtask.is_completed != 0 { 0 } else { 1 }; | |
| 172 | - | ||
| 173 | - | sqlx::query("UPDATE subtasks SET is_completed = ? WHERE id = ?") | |
| 174 | - | .bind(new_completed) | |
| 175 | - | .bind(subtask_id.to_string()) | |
| 176 | - | .execute(pool) | |
| 177 | - | .await | |
| 178 | - | .map_err(CoreError::database)?; | |
| 179 | - | ||
| 180 | 173 | Ok(Some(Subtask { | |
| 181 | 174 | id: parse_uuid(&subtask.id)?.into(), | |
| 182 | 175 | task_id: parse_uuid(&subtask.task_id)?.into(), | |
| 183 | 176 | text: subtask.text, | |
| 184 | 177 | linked_task_id: parse_uuid_opt(subtask.linked_task_id.as_deref())?.map(Into::into), | |
| 185 | - | is_completed: new_completed != 0, | |
| 178 | + | is_completed: subtask.is_completed != 0, | |
| 186 | 179 | position: subtask.position, | |
| 187 | 180 | })) | |
| 188 | 181 | } |
| @@ -79,7 +79,7 @@ pub(crate) async fn list_snoozed( | |||
| 79 | 79 | user_id: UserId, | |
| 80 | 80 | ) -> Result<Vec<Task>> { | |
| 81 | 81 | let sql = format!( | |
| 82 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now') ORDER BY t.snoozed_until ASC", | |
| 82 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.snoozed_until IS NOT NULL AND t.snoozed_until > datetime('now') ORDER BY t.snoozed_until ASC", | |
| 83 | 83 | TASK_SELECT_COLUMNS | |
| 84 | 84 | ); | |
| 85 | 85 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string()]).await | |
| @@ -161,7 +161,7 @@ pub(crate) async fn list_scheduled_for_date( | |||
| 161 | 161 | let date_end = format!("{} 23:59:59", date); | |
| 162 | 162 | ||
| 163 | 163 | let sql = format!( | |
| 164 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NOT NULL AND datetime(t.scheduled_start) >= datetime(?) AND datetime(t.scheduled_start) <= datetime(?) ORDER BY t.scheduled_start ASC", | |
| 164 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NOT NULL AND t.scheduled_start >= ? AND t.scheduled_start <= ? ORDER BY t.scheduled_start ASC", | |
| 165 | 165 | TASK_SELECT_COLUMNS | |
| 166 | 166 | ); | |
| 167 | 167 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), date_start, date_end]).await | |
| @@ -177,7 +177,7 @@ pub(crate) async fn list_unscheduled_due_on_date( | |||
| 177 | 177 | let date_end = format!("{} 23:59:59", date); | |
| 178 | 178 | ||
| 179 | 179 | let sql = format!( | |
| 180 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NULL AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.urgency DESC, t.due ASC", | |
| 180 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NULL AND t.due IS NOT NULL AND t.due >= ? AND t.due <= ? ORDER BY t.urgency DESC, t.due ASC", | |
| 181 | 181 | TASK_SELECT_COLUMNS | |
| 182 | 182 | ); | |
| 183 | 183 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), date_start, date_end]).await | |
| @@ -196,7 +196,7 @@ pub(crate) async fn list_unscheduled_due_between( | |||
| 196 | 196 | let end_str = format_datetime(&end); | |
| 197 | 197 | ||
| 198 | 198 | let sql = format!( | |
| 199 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NULL AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.urgency DESC, t.due ASC", | |
| 199 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.status != 'Completed' AND t.scheduled_start IS NULL AND t.due IS NOT NULL AND t.due >= ? AND t.due <= ? ORDER BY t.urgency DESC, t.due ASC", | |
| 200 | 200 | TASK_SELECT_COLUMNS | |
| 201 | 201 | ); | |
| 202 | 202 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await | |
| @@ -304,7 +304,7 @@ pub(crate) async fn list_completed_between( | |||
| 304 | 304 | let end_str = format_datetime(&end); | |
| 305 | 305 | ||
| 306 | 306 | let sql = format!( | |
| 307 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status = 'Completed' AND t.completed_at IS NOT NULL AND datetime(t.completed_at) >= datetime(?) AND datetime(t.completed_at) <= datetime(?) ORDER BY t.completed_at DESC", | |
| 307 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status = 'Completed' AND t.completed_at IS NOT NULL AND t.completed_at >= ? AND t.completed_at <= ? ORDER BY t.completed_at DESC", | |
| 308 | 308 | TASK_SELECT_COLUMNS | |
| 309 | 309 | ); | |
| 310 | 310 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await | |
| @@ -321,7 +321,7 @@ pub(crate) async fn list_created_between( | |||
| 321 | 321 | let end_str = format_datetime(&end); | |
| 322 | 322 | ||
| 323 | 323 | let sql = format!( | |
| 324 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND datetime(t.created_at) >= datetime(?) AND datetime(t.created_at) <= datetime(?) ORDER BY t.created_at DESC", | |
| 324 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status != 'Deleted' AND t.created_at >= ? AND t.created_at <= ? ORDER BY t.created_at DESC", | |
| 325 | 325 | TASK_SELECT_COLUMNS | |
| 326 | 326 | ); | |
| 327 | 327 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await | |
| @@ -339,7 +339,7 @@ pub(crate) async fn list_became_overdue_between( | |||
| 339 | 339 | ||
| 340 | 340 | // Tasks whose due date is in the given range and are still pending/started (overdue) | |
| 341 | 341 | let sql = format!( | |
| 342 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.due ASC", | |
| 342 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.due IS NOT NULL AND t.due >= ? AND t.due <= ? ORDER BY t.due ASC", | |
| 343 | 343 | TASK_SELECT_COLUMNS | |
| 344 | 344 | ); | |
| 345 | 345 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await | |
| @@ -356,7 +356,7 @@ pub(crate) async fn list_due_between( | |||
| 356 | 356 | let end_str = format_datetime(&end); | |
| 357 | 357 | ||
| 358 | 358 | let sql = format!( | |
| 359 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.due IS NOT NULL AND datetime(t.due) >= datetime(?) AND datetime(t.due) <= datetime(?) ORDER BY t.due ASC, t.urgency DESC", | |
| 359 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.due IS NOT NULL AND t.due >= ? AND t.due <= ? ORDER BY t.due ASC, t.urgency DESC", | |
| 360 | 360 | TASK_SELECT_COLUMNS | |
| 361 | 361 | ); | |
| 362 | 362 | query_tasks(pool, &sql, &[user_id.to_string(), user_id.to_string(), start_str, end_str]).await | |
| @@ -368,8 +368,11 @@ pub(crate) async fn list_available_for_focus( | |||
| 368 | 368 | user_id: UserId, | |
| 369 | 369 | limit: i64, | |
| 370 | 370 | ) -> Result<Vec<Task>> { | |
| 371 | + | // Clamp the caller-supplied limit: a negative value would otherwise mean | |
| 372 | + | // "unbounded" to SQLite and a huge value an unbounded allocation. | |
| 373 | + | let limit = limit.clamp(0, 1000); | |
| 371 | 374 | let sql = format!( | |
| 372 | - | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.is_focus = 0 AND (t.snoozed_until IS NULL OR datetime(t.snoozed_until) <= datetime('now')) AND t.waiting_for_response = 0 ORDER BY t.urgency DESC, t.priority DESC, t.due ASC NULLS LAST LIMIT ?", | |
| 375 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE t.user_id = ? AND t.status NOT IN ('Completed', 'Deleted') AND t.is_focus = 0 AND (t.snoozed_until IS NULL OR t.snoozed_until <= datetime('now')) AND t.waiting_for_response = 0 ORDER BY t.urgency DESC, t.priority DESC, t.due ASC NULLS LAST LIMIT ?", | |
| 373 | 376 | TASK_SELECT_COLUMNS | |
| 374 | 377 | ); | |
| 375 | 378 |
| @@ -110,50 +110,29 @@ impl WeeklyReviewRepository for SqliteWeeklyReviewRepository { | |||
| 110 | 110 | let now = Utc::now(); | |
| 111 | 111 | let completed_at_str = format_datetime(&now); | |
| 112 | 112 | ||
| 113 | - | // Try to get existing review | |
| 114 | - | let existing = self.get_for_week(user_id, week_start).await?; | |
| 115 | - | let existing_vacation_days = existing.as_ref().map(|r| r.vacation_days.clone()).unwrap_or_default(); | |
| 116 | - | ||
| 117 | - | let id = if let Some(existing) = existing { | |
| 118 | - | // Update existing | |
| 119 | - | sqlx::query( | |
| 120 | - | "UPDATE weekly_reviews SET notes = ?, completed_at = ? WHERE id = ?" | |
| 121 | - | ) | |
| 122 | - | .bind(notes) | |
| 123 | - | .bind(&completed_at_str) | |
| 124 | - | .bind(existing.id.to_string()) | |
| 125 | - | .execute(&self.pool) | |
| 126 | - | .await | |
| 127 | - | .map_err(CoreError::database)?; | |
| 128 | - | ||
| 129 | - | existing.id | |
| 130 | - | } else { | |
| 131 | - | // Insert new | |
| 132 | - | let id = WeeklyReviewId::new(); | |
| 133 | - | sqlx::query( | |
| 134 | - | "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes) | |
| 135 | - | VALUES (?, ?, ?, ?, ?)" | |
| 136 | - | ) | |
| 137 | - | .bind(id.to_string()) | |
| 138 | - | .bind(&user_id_str) | |
| 139 | - | .bind(&week_start_str) | |
| 140 | - | .bind(&completed_at_str) | |
| 141 | - | .bind(notes) | |
| 142 | - | .execute(&self.pool) | |
| 143 | - | .await | |
| 144 | - | .map_err(CoreError::database)?; | |
| 145 | - | ||
| 146 | - | id | |
| 147 | - | }; | |
| 113 | + | // Atomic upsert on the (user_id, week_start_date) unique key. Avoids the | |
| 114 | + | // get-then-insert/update race that could double-insert under a | |
| 115 | + | // concurrent first write (sync apply + a UI write). vacation_days and | |
| 116 | + | // the original id are preserved by only updating notes + completed_at. | |
| 117 | + | sqlx::query( | |
| 118 | + | "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes) | |
| 119 | + | VALUES (?, ?, ?, ?, ?) | |
| 120 | + | ON CONFLICT(user_id, week_start_date) | |
| 121 | + | DO UPDATE SET notes = excluded.notes, completed_at = excluded.completed_at", | |
| 122 | + | ) | |
| 123 | + | .bind(WeeklyReviewId::new().to_string()) | |
| 124 | + | .bind(&user_id_str) | |
| 125 | + | .bind(&week_start_str) | |
| 126 | + | .bind(&completed_at_str) | |
| 127 | + | .bind(notes) | |
| 128 | + | .execute(&self.pool) | |
| 129 | + | .await | |
| 130 | + | .map_err(CoreError::database)?; | |
| 148 | 131 | ||
| 149 | - | Ok(WeeklyReview { | |
| 150 | - | id, | |
| 151 | - | user_id, | |
| 152 | - | week_start_date: week_start, | |
| 153 | - | completed_at: now, | |
| 154 | - | notes: notes.to_string(), | |
| 155 | - | vacation_days: existing_vacation_days, | |
| 156 | - | }) | |
| 132 | + | // Re-read to return the persisted row (its id may predate this call). | |
| 133 | + | self.get_for_week(user_id, week_start) | |
| 134 | + | .await? | |
| 135 | + | .ok_or_else(|| CoreError::database_msg("weekly review vanished after upsert")) | |
| 157 | 136 | } | |
| 158 | 137 | ||
| 159 | 138 | #[tracing::instrument(skip_all)] | |
| @@ -168,39 +147,23 @@ impl WeeklyReviewRepository for SqliteWeeklyReviewRepository { | |||
| 168 | 147 | let user_id_str = user_id.to_string(); | |
| 169 | 148 | let week_start_str = week_start.format("%Y-%m-%d").to_string(); | |
| 170 | 149 | let vacation_str = serialize_vacation_days(days); | |
| 171 | - | ||
| 172 | - | // Check if a review row already exists for this week | |
| 173 | - | let existing = self.get_for_week(user_id, week_start).await?; | |
| 174 | - | ||
| 175 | - | if existing.is_some() { | |
| 176 | - | // Update existing row | |
| 177 | - | sqlx::query( | |
| 178 | - | "UPDATE weekly_reviews SET vacation_days = ? WHERE user_id = ? AND week_start_date = ?" | |
| 179 | - | ) | |
| 180 | - | .bind(&vacation_str) | |
| 181 | - | .bind(&user_id_str) | |
| 182 | - | .bind(&week_start_str) | |
| 183 | - | .execute(&self.pool) | |
| 184 | - | .await | |
| 185 | - | .map_err(CoreError::database)?; | |
| 186 | - | } else { | |
| 187 | - | // Insert a new row (review not yet completed, just vacation days) | |
| 188 | - | let id = WeeklyReviewId::new(); | |
| 189 | - | let now = format_datetime(&Utc::now()); | |
| 190 | - | ||
| 191 | - | sqlx::query( | |
| 192 | - | "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days) | |
| 193 | - | VALUES (?, ?, ?, ?, '', ?)" | |
| 194 | - | ) | |
| 195 | - | .bind(id.to_string()) | |
| 196 | - | .bind(&user_id_str) | |
| 197 | - | .bind(&week_start_str) | |
| 198 | - | .bind(&now) | |
| 199 | - | .bind(&vacation_str) | |
| 200 | - | .execute(&self.pool) | |
| 201 | - | .await | |
| 202 | - | .map_err(CoreError::database)?; | |
| 203 | - | } | |
| 150 | + | let now = format_datetime(&Utc::now()); | |
| 151 | + | ||
| 152 | + | // Atomic upsert: insert a vacation-only row or update the existing | |
| 153 | + | // week's vacation_days, without the get-then-write race. | |
| 154 | + | sqlx::query( | |
| 155 | + | "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days) | |
| 156 | + | VALUES (?, ?, ?, ?, '', ?) | |
| 157 | + | ON CONFLICT(user_id, week_start_date) DO UPDATE SET vacation_days = excluded.vacation_days", | |
| 158 | + | ) | |
| 159 | + | .bind(WeeklyReviewId::new().to_string()) | |
| 160 | + | .bind(&user_id_str) | |
| 161 | + | .bind(&week_start_str) | |
| 162 | + | .bind(&now) | |
| 163 | + | .bind(&vacation_str) | |
| 164 | + | .execute(&self.pool) | |
| 165 | + | .await | |
| 166 | + | .map_err(CoreError::database)?; | |
| 204 | 167 | ||
| 205 | 168 | Ok(()) | |
| 206 | 169 | } |
| @@ -73,6 +73,23 @@ impl From<DashboardStats> for DashboardStatsResponse { | |||
| 73 | 73 | #[tauri::command] | |
| 74 | 74 | #[instrument(skip_all)] | |
| 75 | 75 | pub async fn get_dashboard_stats(state: State<'_, Arc<AppState>>) -> Result<DashboardStatsResponse, ApiError> { | |
| 76 | - | let stats = state.stats.get_dashboard_stats(DESKTOP_USER_ID).await?; | |
| 76 | + | // Compute the "today"/"this week" windows from the user's *local* calendar | |
| 77 | + | // day so due-date counts match what the user sees, then hand them to the | |
| 78 | + | // repo as UTC instants. "This week" preserves the prior inclusive | |
| 79 | + | // today..today+7 span (an 8-day exclusive upper bound). | |
| 80 | + | let now = chrono::Utc::now(); | |
| 81 | + | let tz = crate::tz::system_tz(); | |
| 82 | + | let local_today = now.with_timezone(&tz).date_naive(); | |
| 83 | + | let midnight = |d: chrono::NaiveDate| { | |
| 84 | + | crate::tz::local_civil_to_utc(d.and_hms_opt(0, 0, 0).expect("valid midnight")) | |
| 85 | + | }; | |
| 86 | + | let today_start = midnight(local_today); | |
| 87 | + | let tomorrow_start = midnight(local_today + chrono::Duration::days(1)); | |
| 88 | + | let week_end = midnight(local_today + chrono::Duration::days(8)); | |
| 89 | + | ||
| 90 | + | let stats = state | |
| 91 | + | .stats | |
| 92 | + | .get_dashboard_stats(DESKTOP_USER_ID, now, today_start, tomorrow_start, week_end) | |
| 93 | + | .await?; | |
| 77 | 94 | Ok(DashboardStatsResponse::from(stats)) | |
| 78 | 95 | } |
| @@ -16,7 +16,7 @@ | |||
| 16 | 16 | //! `apply_delete` return an error if `table_columns()` returns `None`. | |
| 17 | 17 | ||
| 18 | 18 | use goingson_core::CoreError; | |
| 19 | - | use sqlx::SqliteConnection; | |
| 19 | + | use sqlx::{Row, SqliteConnection}; | |
| 20 | 20 | ||
| 21 | 21 | use super::EMAIL_ACCOUNT_SYNC_COLS; | |
| 22 | 22 | ||
| @@ -104,14 +104,21 @@ pub(crate) fn table_columns(table: &str) -> Option<&'static [&'static str]> { | |||
| 104 | 104 | .map(|(_, cols)| *cols) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | - | /// Apply an INSERT OR REPLACE for a remote change. | |
| 107 | + | /// Apply an upsert for a remote change. | |
| 108 | + | /// | |
| 109 | + | /// Uses `INSERT ... ON CONFLICT(id) DO UPDATE` rather than `INSERT OR REPLACE`. | |
| 110 | + | /// `INSERT OR REPLACE` deletes the conflicting row before reinserting it, which | |
| 111 | + | /// fires `ON DELETE CASCADE` on children (annotations, subtasks, contact_*) and | |
| 112 | + | /// would wipe them on any parent re-upsert. `ON CONFLICT DO UPDATE` mutates the | |
| 113 | + | /// row in place, so children survive and referential integrity holds regardless | |
| 114 | + | /// of FK enforcement. Every syncable table has `id` as its primary key. | |
| 108 | 115 | pub(crate) async fn apply_upsert( | |
| 109 | 116 | conn: &mut SqliteConnection, | |
| 110 | 117 | table: &str, | |
| 111 | 118 | _row_id: &str, | |
| 112 | 119 | data: &serde_json::Value, | |
| 113 | 120 | ) -> Result<(), CoreError> { | |
| 114 | - | // Email accounts use ON CONFLICT to preserve local credentials | |
| 121 | + | // Email accounts use a bespoke ON CONFLICT that preserves local credentials | |
| 115 | 122 | if table == "email_accounts" { | |
| 116 | 123 | return apply_email_account_upsert(conn, data).await; | |
| 117 | 124 | } | |
| @@ -119,16 +126,42 @@ pub(crate) async fn apply_upsert( | |||
| 119 | 126 | let columns = table_columns(table) | |
| 120 | 127 | .ok_or_else(|| CoreError::bad_request(format!("unknown syncable table: {}", table)))?; | |
| 121 | 128 | ||
| 122 | - | let col_list = columns.join(", "); | |
| 123 | - | let placeholders = columns.iter().map(|_| "?").collect::<Vec<_>>().join(", "); | |
| 129 | + | // A remote null for a NOT NULL column is invalid input the changelog never | |
| 130 | + | // emits. INSERT OR REPLACE silently coerced it to the column default; to | |
| 131 | + | // keep that tolerance under ON CONFLICT, omit such columns: a new row then | |
| 132 | + | // takes the schema default and an existing row keeps its current value. | |
| 133 | + | // Nullable columns keep null (so legitimate clears — e.g. un-snoozing — | |
| 134 | + | // still propagate). NOT NULL columns are derived from the live schema, not | |
| 135 | + | // a hand-maintained list, so they can't drift. | |
| 136 | + | let not_null = not_null_columns(conn, table).await?; | |
| 137 | + | let included: Vec<&str> = columns | |
| 138 | + | .iter() | |
| 139 | + | .copied() | |
| 140 | + | .filter(|c| !(data[*c].is_null() && not_null.contains(*c))) | |
| 141 | + | .collect(); | |
| 142 | + | ||
| 143 | + | let col_list = included.join(", "); | |
| 144 | + | let placeholders = included.iter().map(|_| "?").collect::<Vec<_>>().join(", "); | |
| 145 | + | let update_set = included | |
| 146 | + | .iter() | |
| 147 | + | .filter(|c| **c != "id") | |
| 148 | + | .map(|c| format!("{} = excluded.{}", c, c)) | |
| 149 | + | .collect::<Vec<_>>() | |
| 150 | + | .join(", "); | |
| 151 | + | // If only `id` survived, there is nothing to update on conflict. | |
| 152 | + | let conflict = if update_set.is_empty() { | |
| 153 | + | "DO NOTHING".to_string() | |
| 154 | + | } else { | |
| 155 | + | format!("DO UPDATE SET {}", update_set) | |
| 156 | + | }; | |
| 124 | 157 | let sql = format!( | |
| 125 | - | "INSERT OR REPLACE INTO {} ({}) VALUES ({})", | |
| 126 | - | table, col_list, placeholders | |
| 158 | + | "INSERT INTO {} ({}) VALUES ({}) ON CONFLICT(id) {}", | |
| 159 | + | table, col_list, placeholders, conflict | |
| 127 | 160 | ); | |
| 128 | 161 | ||
| 129 | 162 | let mut query = sqlx::query(&sql); | |
| 130 | 163 | ||
| 131 | - | for col in columns { | |
| 164 | + | for col in &included { | |
| 132 | 165 | query = bind_json_value(query, &data[*col]); | |
| 133 | 166 | } | |
| 134 | 167 | ||
| @@ -140,6 +173,31 @@ pub(crate) async fn apply_upsert( | |||
| 140 | 173 | Ok(()) | |
| 141 | 174 | } | |
| 142 | 175 | ||
| 176 | + | /// Names of the NOT NULL columns of a table, read from the live schema. | |
| 177 | + | /// | |
| 178 | + | /// Derived via `PRAGMA table_info` rather than a static list so it can never | |
| 179 | + | /// drift from the migrations. `table` is already validated against the column | |
| 180 | + | /// whitelist by the caller, so interpolating it is safe. | |
| 181 | + | async fn not_null_columns( | |
| 182 | + | conn: &mut SqliteConnection, | |
| 183 | + | table: &str, | |
| 184 | + | ) -> Result<std::collections::HashSet<String>, CoreError> { | |
| 185 | + | let rows = sqlx::query(&format!("PRAGMA table_info({})", table)) | |
| 186 | + | .fetch_all(&mut *conn) | |
| 187 | + | .await | |
| 188 | + | .map_err(CoreError::database)?; | |
| 189 | + | ||
| 190 | + | let mut set = std::collections::HashSet::new(); | |
| 191 | + | for row in rows { | |
| 192 | + | let notnull: i64 = row.try_get("notnull").map_err(CoreError::database)?; | |
| 193 | + | if notnull != 0 { | |
| 194 | + | let name: String = row.try_get("name").map_err(CoreError::database)?; | |
| 195 | + | set.insert(name); | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | Ok(set) | |
| 199 | + | } | |
| 200 | + | ||
| 143 | 201 | /// Apply an upsert for email_accounts that preserves local credentials. | |
| 144 | 202 | /// | |
| 145 | 203 | /// Uses INSERT ... ON CONFLICT(id) DO UPDATE to only touch the 16 config columns, |
| @@ -238,6 +238,59 @@ use sqlx::SqlitePool; | |||
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | 240 | #[tokio::test] | |
| 241 | + | async fn apply_upsert_parent_reupsert_preserves_children() { | |
| 242 | + | // Re-upserting a parent task must not cascade-delete its annotations. | |
| 243 | + | // INSERT OR REPLACE would; ON CONFLICT DO UPDATE must not. Enforce FKs | |
| 244 | + | // so the cascade would actually fire if the row were deleted. | |
| 245 | + | let pool = setup_test_db().await; | |
| 246 | + | let user_id = create_test_user(&pool).await; | |
| 247 | + | let task_id = uuid::Uuid::new_v4().to_string(); | |
| 248 | + | let annotation_id = uuid::Uuid::new_v4().to_string(); | |
| 249 | + | let now = now_sql(); | |
| 250 | + | ||
| 251 | + | let task = |status: &str| { | |
| 252 | + | json!({ | |
| 253 | + | "id": task_id, "project_id": null, "description": "Parent", | |
| 254 | + | "status": status, "priority": "Medium", "due": null, "tags": "", | |
| 255 | + | "urgency": 0.0, "recurrence": "None", "recurrence_rule": null, | |
| 256 | + | "created_at": now, "user_id": user_id, "recurrence_parent_id": null, | |
| 257 | + | "source_email_id": null, "snoozed_until": null, "waiting_for_response": 0, | |
| 258 | + | "waiting_since": null, "expected_response_date": null, "scheduled_start": null, | |
| 259 | + | "scheduled_duration": null, "is_focus": 0, "focus_set_at": null, | |
| 260 | + | "contact_id": null, "milestone_id": null, "completed_at": null, | |
| 261 | + | "estimated_minutes": null, "actual_minutes": null, | |
| 262 | + | }) | |
| 263 | + | }; | |
| 264 | + | ||
| 265 | + | let mut conn = pool.acquire().await.unwrap(); | |
| 266 | + | sqlx::query("PRAGMA foreign_keys = ON").execute(&mut *conn).await.unwrap(); | |
| 267 | + | ||
| 268 | + | apply::apply_upsert(&mut conn, "tasks", &task_id, &task("Active")).await.unwrap(); | |
| 269 | + | ||
| 270 | + | let annotation = json!({ | |
| 271 | + | "id": annotation_id, "task_id": task_id, "timestamp": now, "note": "child note", | |
| 272 | + | }); | |
| 273 | + | apply::apply_upsert(&mut conn, "annotations", &annotation_id, &annotation).await.unwrap(); | |
| 274 | + | ||
| 275 | + | // Re-upsert the parent (e.g. a remote status change). | |
| 276 | + | apply::apply_upsert(&mut conn, "tasks", &task_id, &task("Completed")).await.unwrap(); | |
| 277 | + | ||
| 278 | + | let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM annotations WHERE task_id = ?") | |
| 279 | + | .bind(&task_id) | |
| 280 | + | .fetch_one(&pool) | |
| 281 | + | .await | |
| 282 | + | .unwrap(); | |
| 283 | + | assert_eq!(count.0, 1, "annotation should survive parent re-upsert"); | |
| 284 | + | ||
| 285 | + | let status: (String,) = sqlx::query_as("SELECT status FROM tasks WHERE id = ?") | |
| 286 | + | .bind(&task_id) | |
| 287 | + | .fetch_one(&pool) | |
| 288 | + | .await | |
| 289 | + | .unwrap(); | |
| 290 | + | assert_eq!(status.0, "Completed"); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | #[tokio::test] | |
| 241 | 294 | async fn apply_upsert_rejects_unknown_table() { | |
| 242 | 295 | let pool = setup_test_db().await; | |
| 243 | 296 | let data = json!({"id": "abc"}); |