1use crate::ids::WorkspaceId;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, bail};
9use chrono::{NaiveDate, NaiveTime};
10use serde_json::Value;
11use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow};
12use sqlx::{Connection as _, Row, Sqlite, SqliteConnection, SqlitePool, Transaction};
13
14use crate::choices::{TaskPriority, TaskSource, TaskStatus};
15use crate::ids::{new_id, now};
16use crate::recurrence::{
17 RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
18 RecurrenceRule, RecurrenceSeriesState, TimeZoneId, WeekdaySet,
19};
20use crate::types::{
21 MutableEntityType, RecurrenceOccurrence, RecurrencePauseInterval, RecurrenceSeries,
22 RecurrenceSeriesLabel, Task,
23};
24use crate::workspaces::ensure_default_workspace;
25
26static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
27const MIGRATION_BACKUP_KEEP: usize = 20;
28
29#[derive(Clone)]
30pub struct Database {
31 pool: SqlitePool,
32 path: PathBuf,
33}
34
35impl Database {
36 pub async fn open(path: &Path) -> Result<Self> {
37 Ok(Self {
38 pool: open_db(path).await?,
39 path: path.to_path_buf(),
40 })
41 }
42
43 pub fn path(&self) -> &Path {
44 &self.path
45 }
46
47 pub fn latest_schema_version() -> Option<i64> {
48 MIGRATOR.iter().map(|migration| migration.version).max()
49 }
50
51 pub async fn meta(&self, key: &str) -> Result<Option<String>> {
52 let mut conn = self.acquire().await?;
53 get_meta(&mut conn, key).await
54 }
55
56 pub async fn conflict_exists(
57 &self,
58 workspace_id: &WorkspaceId,
59 task_id: &crate::ids::TaskId,
60 field: &str,
61 ) -> Result<bool> {
62 let mut conn = self.acquire().await?;
63 conflict_exists(&mut conn, workspace_id, task_id, field).await
64 }
65
66 pub(crate) async fn acquire(&self) -> Result<sqlx::pool::PoolConnection<Sqlite>> {
67 Ok(self.pool.acquire().await?)
68 }
69}
70
71pub(crate) async fn open_db(path: &Path) -> Result<SqlitePool> {
72 let existed_before_open = path.exists();
73 if let Some(parent) = path.parent() {
74 fs::create_dir_all(parent)
75 .with_context(|| format!("could not create {}", parent.display()))?;
76 }
77 let options = SqliteConnectOptions::from_str(&path.display().to_string())?
78 .create_if_missing(true)
79 .foreign_keys(true)
80 .journal_mode(SqliteJournalMode::Wal)
81 .busy_timeout(Duration::from_secs(5));
82 let pool = SqlitePoolOptions::new()
83 .max_connections(1)
84 .connect_with(options)
85 .await
86 .with_context(|| format!("could not open {}", path.display()))?;
87 backup_before_pending_migrations(path, existed_before_open, &pool).await?;
88 MIGRATOR.run(&pool).await?;
89 initialize_meta(&pool).await?;
90 let mut conn = pool.acquire().await?;
91 ensure_default_workspace(&mut conn).await?;
92 Ok(pool)
93}
94
95async fn backup_before_pending_migrations(
96 path: &Path,
97 existed_before_open: bool,
98 pool: &SqlitePool,
99) -> Result<()> {
100 if !migration_backups_enabled() || !existed_before_open || !has_pending_migrations(pool).await?
101 {
102 return Ok(());
103 }
104 let backup_path = migration_backup_path(path)?;
105 run_sqlite_backup(path, &backup_path)?;
106 prune_migration_backups(path)?;
107 Ok(())
108}
109
110fn migration_backups_enabled() -> bool {
111 std::env::var_os("AVEN_DEV_MIGRATION_BACKUPS").is_some()
112}
113
114async fn has_pending_migrations(pool: &SqlitePool) -> Result<bool> {
115 let applied_versions =
116 match sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations")
117 .fetch_all(pool)
118 .await
119 {
120 Ok(versions) => versions,
121 Err(error) => {
122 let Some(db_error) = error.as_database_error() else {
123 return Err(error.into());
124 };
125 if db_error.code().as_deref() == Some("1") {
126 return Ok(MIGRATOR.iter().next().is_some());
127 }
128 return Err(error.into());
129 }
130 };
131 Ok(MIGRATOR
132 .iter()
133 .any(|migration| !applied_versions.contains(&migration.version)))
134}
135
136fn migration_backup_path(path: &Path) -> Result<PathBuf> {
137 default_sqlite_backup_path(path, "before-migrate")
138}
139
140pub fn default_backup_path(path: &Path, reason: &str) -> Result<PathBuf> {
141 backup_path_with_extension(path, reason, "aven-backup.tar.zst")
142}
143
144pub fn default_sqlite_backup_path(path: &Path, reason: &str) -> Result<PathBuf> {
145 backup_path_with_extension(path, reason, "sqlite")
146}
147
148fn backup_path_with_extension(path: &Path, reason: &str, extension: &str) -> Result<PathBuf> {
149 let parent = path.parent().unwrap_or_else(|| Path::new("."));
150 let backup_dir = parent.join("backups");
151 fs::create_dir_all(&backup_dir)
152 .with_context(|| format!("could not create {}", backup_dir.display()))?;
153 let stem = path
154 .file_name()
155 .and_then(|name| name.to_str())
156 .unwrap_or("db.sqlite");
157 Ok(backup_dir.join(format!(
158 "{stem}.{reason}-{}.{}",
159 backup_timestamp()?,
160 extension
161 )))
162}
163
164pub fn backup_database(source: &Path, backup: &Path) -> Result<()> {
165 if let Some(parent) = backup.parent() {
166 fs::create_dir_all(parent)
167 .with_context(|| format!("could not create {}", parent.display()))?;
168 }
169 run_sqlite_backup(source, backup)
170}
171
172pub fn wal_path(path: &Path) -> PathBuf {
173 PathBuf::from(format!("{}-wal", path.display()))
174}
175
176pub fn shm_path(path: &Path) -> PathBuf {
177 PathBuf::from(format!("{}-shm", path.display()))
178}
179
180pub async fn restore_database_file(target: &Path, source: &Path) -> Result<PathBuf> {
181 validate_sqlite_source(source).await?;
182 let safety = default_sqlite_backup_path(target, "before-restore")?;
183 backup_database(target, &safety)?;
184 let staging = target.with_extension("restore-staging");
185 if staging.exists() {
186 fs::remove_file(&staging)
187 .with_context(|| format!("could not remove {}", staging.display()))?;
188 }
189 fs::copy(source, &staging).with_context(|| {
190 format!(
191 "could not copy {} -> {}",
192 source.display(),
193 staging.display()
194 )
195 })?;
196 for sidecar in [wal_path(target), shm_path(target)] {
197 if sidecar.exists() {
198 fs::remove_file(&sidecar)
199 .with_context(|| format!("could not remove {}", sidecar.display()))?;
200 }
201 }
202 fs::rename(&staging, target)
203 .with_context(|| format!("could not replace {}", target.display()))?;
204 Ok(safety)
205}
206
207async fn validate_sqlite_source(source: &Path) -> Result<()> {
208 let mut conn = sqlx::SqliteConnection::connect_with(
209 &SqliteConnectOptions::new()
210 .filename(source)
211 .read_only(true)
212 .foreign_keys(true),
213 )
214 .await
215 .with_context(|| format!("could not open source {}", source.display()))?;
216 let quick_check: String = sqlx::query_scalar("PRAGMA quick_check")
217 .fetch_one(&mut conn)
218 .await?;
219 if quick_check != "ok" {
220 bail!("error backup-source-corrupt quick_check={quick_check}");
221 }
222 Ok(())
223}
224
225fn backup_timestamp() -> Result<u64> {
226 Ok(SystemTime::now()
227 .duration_since(UNIX_EPOCH)
228 .context("system clock is before unix epoch")?
229 .as_secs())
230}
231
232fn run_sqlite_backup(source: &Path, backup: &Path) -> Result<()> {
233 let backup_sql = format!(".backup '{}'", sqlite_single_quoted(backup));
234 let output = Command::new("sqlite3")
235 .arg(source)
236 .arg(backup_sql)
237 .output()
238 .context("could not run sqlite3 for backup")?;
239 if !output.status.success() {
240 let stderr = String::from_utf8_lossy(&output.stderr);
241 bail!(
242 "sqlite3 backup failed status={} stderr={}",
243 output.status,
244 stderr.trim()
245 );
246 }
247 Ok(())
248}
249
250fn sqlite_single_quoted(path: &Path) -> String {
251 path.display().to_string().replace('\'', "''")
252}
253
254fn prune_migration_backups(path: &Path) -> Result<()> {
255 let Some(parent) = path.parent() else {
256 return Ok(());
257 };
258 let backup_dir = parent.join("backups");
259 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
260 return Ok(());
261 };
262 let prefix = format!("{file_name}.before-migrate-");
263 let mut backups = fs::read_dir(&backup_dir)
264 .with_context(|| format!("could not read {}", backup_dir.display()))?
265 .filter_map(|entry| entry.ok())
266 .filter(|entry| {
267 entry
268 .file_name()
269 .to_str()
270 .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".sqlite"))
271 })
272 .collect::<Vec<_>>();
273 backups.sort_by_key(|entry| entry.file_name());
274 let remove_count = backups.len().saturating_sub(MIGRATION_BACKUP_KEEP);
275 for entry in backups.into_iter().take(remove_count) {
276 let path = entry.path();
277 fs::remove_file(&path).with_context(|| format!("could not remove {}", path.display()))?;
278 }
279 Ok(())
280}
281
282async fn initialize_meta(pool: &SqlitePool) -> Result<()> {
283 let mut conn = pool.acquire().await?;
284 insert_meta_if_missing(&mut conn, "client_id", &new_id()).await?;
285 insert_meta_if_missing(&mut conn, "sync_cursor", "0").await?;
286 insert_meta_if_missing(&mut conn, "local_seq", "0").await?;
287 Ok(())
288}
289
290pub(crate) async fn current_schema_version(conn: &mut SqliteConnection) -> Result<i64> {
291 let version: Option<i64> = sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations")
292 .fetch_one(conn)
293 .await?;
294 Ok(version.unwrap_or(0))
295}
296
297pub(crate) async fn get_meta(conn: &mut SqliteConnection, key: &str) -> Result<Option<String>> {
298 Ok(
299 sqlx::query_scalar!("SELECT value FROM meta WHERE key = ?", key)
300 .fetch_optional(&mut *conn)
301 .await?,
302 )
303}
304
305pub(crate) async fn set_meta(conn: &mut SqliteConnection, key: &str, value: &str) -> Result<()> {
306 sqlx::query!(
307 "INSERT INTO meta(key, value) VALUES (?, ?)
308 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
309 key,
310 value,
311 )
312 .execute(&mut *conn)
313 .await?;
314 Ok(())
315}
316
317pub(crate) async fn begin_immediate(
318 conn: &mut SqliteConnection,
319) -> sqlx::Result<Transaction<'_, Sqlite>> {
320 conn.begin_with("BEGIN IMMEDIATE").await
321}
322
323async fn insert_meta_if_missing(conn: &mut SqliteConnection, key: &str, value: &str) -> Result<()> {
324 sqlx::query!(
325 "INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)",
326 key,
327 value
328 )
329 .execute(&mut *conn)
330 .await?;
331 Ok(())
332}
333
334async fn next_local_seq(conn: &mut SqliteConnection) -> Result<i64> {
335 let seq = get_meta(conn, "local_seq")
336 .await?
337 .unwrap_or_else(|| "0".to_string())
338 .parse::<i64>()?
339 + 1;
340 set_meta(conn, "local_seq", &seq.to_string()).await?;
341 Ok(seq)
342}
343
344pub(crate) async fn insert_change(
345 conn: &mut SqliteConnection,
346 entity_type: &str,
347 entity_id: &str,
348 field: Option<&str>,
349 op_type: &str,
350 payload: Value,
351 base_version: Option<&str>,
352) -> Result<String> {
353 let change_id = new_id();
354 let client_id = get_meta(conn, "client_id")
355 .await?
356 .context("missing client id")?;
357 let local_seq = next_local_seq(conn).await?;
358 let created_at = now();
359 let payload = payload.to_string();
360 sqlx::query!(
361 "INSERT INTO changes(change_id, client_id, local_seq, entity_type, entity_id, field,
362 op_type, payload, base_version, created_at)
363 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
364 change_id,
365 client_id,
366 local_seq,
367 entity_type,
368 entity_id,
369 field,
370 op_type,
371 payload,
372 base_version,
373 created_at,
374 )
375 .execute(&mut *conn)
376 .await?;
377 Ok(change_id)
378}
379
380pub(crate) struct IdentifiedChange<'a> {
381 pub change_id: &'a str,
382 pub entity_type: &'a str,
383 pub entity_id: &'a str,
384 pub field: Option<&'a str>,
385 pub op_type: &'a str,
386 pub payload: Value,
387 pub base_version: Option<&'a str>,
388 pub created_at: &'a str,
389}
390
391pub(crate) async fn insert_change_with_identity(
392 conn: &mut SqliteConnection,
393 change: IdentifiedChange<'_>,
394) -> Result<()> {
395 let IdentifiedChange {
396 change_id,
397 entity_type,
398 entity_id,
399 field,
400 op_type,
401 payload,
402 base_version,
403 created_at,
404 } = change;
405 let payload = payload.to_string();
406 let existing = sqlx::query(
407 "SELECT entity_type, entity_id, field, op_type, payload, base_version, created_at
408 FROM changes WHERE change_id = ?",
409 )
410 .bind(change_id)
411 .fetch_optional(&mut *conn)
412 .await?;
413 if let Some(existing) = existing {
414 let equal = existing.try_get::<String, _>("entity_type")? == entity_type
415 && existing.try_get::<String, _>("entity_id")? == entity_id
416 && existing.try_get::<Option<String>, _>("field")?.as_deref() == field
417 && existing.try_get::<String, _>("op_type")? == op_type
418 && existing.try_get::<String, _>("payload")? == payload
419 && existing
420 .try_get::<Option<String>, _>("base_version")?
421 .as_deref()
422 == base_version
423 && existing.try_get::<String, _>("created_at")? == created_at;
424 if equal {
425 return Ok(());
426 }
427 bail!("error recurrence-generation-conflict change_id={change_id}");
428 }
429
430 let client_id = get_meta(conn, "client_id")
431 .await?
432 .context("missing client id")?;
433 let local_seq = next_local_seq(conn).await?;
434 sqlx::query(
435 "INSERT INTO changes(change_id, client_id, local_seq, entity_type, entity_id, field,
436 op_type, payload, base_version, created_at)
437 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
438 )
439 .bind(change_id)
440 .bind(client_id)
441 .bind(local_seq)
442 .bind(entity_type)
443 .bind(entity_id)
444 .bind(field)
445 .bind(op_type)
446 .bind(payload)
447 .bind(base_version)
448 .bind(created_at)
449 .execute(&mut *conn)
450 .await?;
451 Ok(())
452}
453
454fn optional_task_date(value: String) -> Option<String> {
455 (!value.is_empty()).then_some(value)
456}
457
458pub(crate) fn task_from_row(row: &SqliteRow) -> Result<Task> {
459 Ok(Task {
460 id: row.try_get("id")?,
461 workspace_id: row.try_get("workspace_id")?,
462 title: row.try_get("title")?,
463 description: row.try_get("description")?,
464 project_id: row.try_get("project_id")?,
465 project_key: row.try_get("project_key")?,
466 project_prefix: row.try_get("project_prefix")?,
467 status: TaskStatus::parse(row.try_get::<String, _>("status")?.as_str())?,
468 priority: TaskPriority::parse(row.try_get::<String, _>("priority")?.as_str())?,
469 source: TaskSource::parse(row.try_get::<String, _>("source")?.as_str())?,
470 created_at: row.try_get("created_at")?,
471 updated_at: row.try_get("updated_at")?,
472 queue_activity_at: row.try_get("queue_activity_at")?,
473 available_at: optional_task_date(row.try_get("available_at")?),
474 due_on: optional_task_date(row.try_get("due_on")?),
475 deleted: row.try_get::<i64, _>("deleted")? != 0,
476 is_epic: row.try_get::<i64, _>("is_epic")? != 0,
477 })
478}
479
480pub(crate) fn recurrence_series_from_row(row: &SqliteRow) -> Result<RecurrenceSeries> {
481 let frequency = RecurrenceFrequency::parse(&row.try_get::<String, _>("frequency")?)?;
482 let interval = u32::try_from(row.try_get::<i64, _>("interval")?)
483 .context("recurrence interval must fit u32")?;
484 let weekdays = row
485 .try_get::<String, _>("weekdays")?
486 .parse::<WeekdaySet>()
487 .map_err(anyhow::Error::msg)?;
488 let rule = RecurrenceRule::new(frequency, interval, weekdays)?;
489 let start_on = row
490 .try_get::<String, _>("start_on")?
491 .parse::<NaiveDate>()
492 .context("invalid recurrence start date")?;
493 let available_local_time = optional_text(row.try_get("available_local_time")?)
494 .map(|value| {
495 value
496 .parse::<NaiveTime>()
497 .context("invalid recurrence availability time")
498 })
499 .transpose()?;
500 let initial_status = TaskStatus::parse(&row.try_get::<String, _>("initial_status")?)?;
501 if !initial_status.is_open() {
502 bail!("recurrence initial status must be open");
503 }
504 let state = RecurrenceSeriesState::parse(&row.try_get::<String, _>("state")?)?;
505 let stopped_at = optional_text(row.try_get("stopped_at")?);
506 if matches!(state, RecurrenceSeriesState::Stopped) != stopped_at.is_some() {
507 bail!("recurrence stopped state and stop time must agree");
508 }
509 let deleted = row.try_get::<i64, _>("deleted")?;
510 if !matches!(deleted, 0 | 1) {
511 bail!("recurrence deleted value must be zero or one");
512 }
513 Ok(RecurrenceSeries {
514 workspace_id: row.try_get("workspace_id")?,
515 id: row.try_get::<String, _>("id")?.parse()?,
516 title: row.try_get("title")?,
517 description: row.try_get("description")?,
518 project_id: row.try_get("project_id")?,
519 priority: TaskPriority::parse(&row.try_get::<String, _>("priority")?)?,
520 initial_status,
521 rule,
522 timezone: row
523 .try_get::<String, _>("timezone")?
524 .parse::<TimeZoneId>()?,
525 start_on,
526 available_local_time,
527 due_policy: RecurrenceDuePolicy::parse(&row.try_get::<String, _>("due_policy")?)?,
528 state,
529 stopped_at,
530 created_at: row.try_get("created_at")?,
531 updated_at: row.try_get("updated_at")?,
532 deleted: deleted != 0,
533 })
534}
535
536pub(crate) fn recurrence_series_label_from_row(row: &SqliteRow) -> Result<RecurrenceSeriesLabel> {
537 Ok(RecurrenceSeriesLabel {
538 workspace_id: row.try_get("workspace_id")?,
539 series_id: row.try_get::<String, _>("series_id")?.parse()?,
540 label: row.try_get("label")?,
541 })
542}
543
544pub(crate) fn recurrence_occurrence_from_row(row: &SqliteRow) -> Result<RecurrenceOccurrence> {
545 let task_id = optional_text(row.try_get("task_id")?)
546 .map(|value| value.parse())
547 .transpose()?;
548 let outcome = optional_text(row.try_get("outcome")?)
549 .map(|value| RecurrenceOutcome::parse(&value))
550 .transpose()?;
551 let resolved_at = optional_text(row.try_get("resolved_at")?);
552 let outcome_change_id = optional_text(row.try_get("outcome_change_id")?);
553 let projection_state =
554 RecurrenceProjectionState::parse(&row.try_get::<String, _>("projection_state")?)?;
555 let archived_at = optional_text(row.try_get("archived_at")?);
556 let valid_shape = match projection_state {
557 RecurrenceProjectionState::Projected => {
558 task_id.is_some()
559 && outcome.is_none()
560 && resolved_at.is_none()
561 && outcome_change_id.is_none()
562 && archived_at.is_none()
563 }
564 RecurrenceProjectionState::Resolved => {
565 task_id.is_some()
566 && outcome.is_some()
567 && resolved_at.is_some()
568 && outcome_change_id.is_some()
569 && archived_at.is_none()
570 }
571 RecurrenceProjectionState::Archived => {
572 task_id.is_some()
573 && outcome.is_none()
574 && resolved_at.is_none()
575 && outcome_change_id.is_none()
576 && archived_at.is_some()
577 }
578 };
579 if !valid_shape {
580 bail!("recurrence occurrence fields do not match projection state");
581 }
582 Ok(RecurrenceOccurrence {
583 workspace_id: row.try_get("workspace_id")?,
584 series_id: row.try_get::<String, _>("series_id")?.parse()?,
585 slot_on: row
586 .try_get::<String, _>("slot_on")?
587 .parse::<NaiveDate>()
588 .context("invalid recurrence slot date")?,
589 task_id,
590 outcome,
591 resolved_at,
592 outcome_change_id,
593 projection_state,
594 archived_at,
595 })
596}
597
598pub(crate) fn recurrence_pause_interval_from_row(
599 row: &SqliteRow,
600) -> Result<RecurrencePauseInterval> {
601 let suspended_slot_on = optional_text(row.try_get("suspended_slot_on")?)
602 .map(|value| {
603 value
604 .parse::<NaiveDate>()
605 .context("invalid suspended recurrence slot date")
606 })
607 .transpose()?;
608 let suspended_task_id = optional_text(row.try_get("suspended_task_id")?)
609 .map(|value| value.parse())
610 .transpose()?;
611 let resumed_at = optional_text(row.try_get("resumed_at")?);
612 let resolved_by_change_id = optional_text(row.try_get("resolved_by_change_id")?);
613 if resumed_at.is_some() != resolved_by_change_id.is_some() {
614 bail!("recurrence pause resume time and change must agree");
615 }
616 if suspended_slot_on.is_some() != suspended_task_id.is_some() {
617 bail!("recurrence suspended slot and task must agree");
618 }
619 Ok(RecurrencePauseInterval {
620 workspace_id: row.try_get("workspace_id")?,
621 id: row.try_get("id")?,
622 series_id: row.try_get::<String, _>("series_id")?.parse()?,
623 paused_at: row.try_get("paused_at")?,
624 resumed_at,
625 suspended_slot_on,
626 suspended_task_id,
627 created_by_change_id: row.try_get("created_by_change_id")?,
628 resolved_by_change_id,
629 })
630}
631
632fn optional_text(value: String) -> Option<String> {
633 (!value.is_empty()).then_some(value)
634}
635
636pub(crate) async fn entity_field_version(
637 conn: &mut SqliteConnection,
638 workspace_id: &WorkspaceId,
639 entity_type: MutableEntityType,
640 entity_id: &str,
641 field: &str,
642) -> Result<Option<String>> {
643 Ok(sqlx::query_scalar(
644 "SELECT version FROM field_versions
645 WHERE workspace_id = ? AND entity_type = ? AND entity_id = ? AND field = ?",
646 )
647 .bind(workspace_id)
648 .bind(entity_type.as_str())
649 .bind(entity_id)
650 .bind(field)
651 .fetch_optional(&mut *conn)
652 .await?)
653}
654
655pub(crate) async fn set_entity_field_version(
656 conn: &mut SqliteConnection,
657 workspace_id: &WorkspaceId,
658 entity_type: MutableEntityType,
659 entity_id: &str,
660 field: &str,
661 version: &str,
662) -> Result<()> {
663 sqlx::query(
664 "INSERT INTO field_versions(workspace_id, entity_type, entity_id, field, version)
665 VALUES (?, ?, ?, ?, ?)
666 ON CONFLICT(workspace_id, entity_type, entity_id, field)
667 DO UPDATE SET version = excluded.version",
668 )
669 .bind(workspace_id)
670 .bind(entity_type.as_str())
671 .bind(entity_id)
672 .bind(field)
673 .bind(version)
674 .execute(&mut *conn)
675 .await?;
676 Ok(())
677}
678
679pub(crate) async fn field_version(
680 conn: &mut SqliteConnection,
681 entity_id: &str,
682 field: &str,
683) -> Result<Option<String>> {
684 let workspace_id =
685 sqlx::query_scalar::<_, WorkspaceId>("SELECT workspace_id FROM tasks WHERE id = ? LIMIT 1")
686 .bind(entity_id)
687 .fetch_optional(&mut *conn)
688 .await?;
689 let Some(workspace_id) = workspace_id else {
690 return Ok(None);
691 };
692 entity_field_version(
693 conn,
694 &workspace_id,
695 MutableEntityType::Task,
696 entity_id,
697 field,
698 )
699 .await
700}
701
702pub(crate) async fn set_field_version(
703 conn: &mut SqliteConnection,
704 entity_id: &str,
705 field: &str,
706 version: &str,
707) -> Result<()> {
708 let workspace_id =
709 sqlx::query_scalar::<_, WorkspaceId>("SELECT workspace_id FROM tasks WHERE id = ? LIMIT 1")
710 .bind(entity_id)
711 .fetch_optional(&mut *conn)
712 .await?;
713 if let Some(workspace_id) = workspace_id {
714 set_entity_field_version(
715 conn,
716 &workspace_id,
717 MutableEntityType::Task,
718 entity_id,
719 field,
720 version,
721 )
722 .await?;
723 }
724 Ok(())
725}
726
727pub(crate) async fn entity_conflict_exists(
728 conn: &mut SqliteConnection,
729 workspace_id: &WorkspaceId,
730 entity_type: MutableEntityType,
731 entity_id: &str,
732 field: &str,
733) -> Result<bool> {
734 Ok(sqlx::query_scalar::<_, i64>(
735 "SELECT count(*) FROM conflicts
736 WHERE workspace_id = ? AND entity_type = ? AND entity_id = ?
737 AND field = ? AND resolved = 0 LIMIT 1",
738 )
739 .bind(workspace_id)
740 .bind(entity_type.as_str())
741 .bind(entity_id)
742 .bind(field)
743 .fetch_one(&mut *conn)
744 .await?
745 > 0)
746}
747
748pub(crate) async fn conflict_exists(
749 conn: &mut SqliteConnection,
750 workspace_id: &WorkspaceId,
751 task_id: &crate::ids::TaskId,
752 field: &str,
753) -> Result<bool> {
754 entity_conflict_exists(
755 conn,
756 workspace_id,
757 MutableEntityType::Task,
758 task_id.as_str(),
759 field,
760 )
761 .await
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[tokio::test]
769 async fn task_from_row_maps_empty_dates_to_absence() {
770 let mut conn = SqliteConnection::connect(":memory:")
771 .await
772 .expect("open db");
773 let row = sqlx::query(
774 "SELECT 'TASK000000000001' AS id,
775 '0000000000000000' AS workspace_id,
776 'optional dates' AS title,
777 '' AS description,
778 '0000000000000001' AS project_id,
779 'app' AS project_key,
780 'APP' AS project_prefix,
781 'todo' AS status,
782 'none' AS priority,
783 'unknown' AS source,
784 't' AS created_at,
785 't' AS updated_at,
786 't' AS queue_activity_at,
787 '' AS available_at,
788 '' AS due_on,
789 0 AS deleted,
790 0 AS is_epic",
791 )
792 .fetch_one(&mut conn)
793 .await
794 .expect("row");
795
796 let task = task_from_row(&row).unwrap();
797
798 assert_eq!(task.available_at, None);
799 assert_eq!(task.due_on, None);
800 }
801
802 #[test]
803 fn task_date_boundary_preserves_present_values() {
804 assert_eq!(
805 optional_task_date("2099-01-01T00:00:00Z".to_string()).as_deref(),
806 Some("2099-01-01T00:00:00Z")
807 );
808 assert_eq!(
809 optional_task_date("2099-01-01".to_string()).as_deref(),
810 Some("2099-01-01")
811 );
812 }
813
814 #[tokio::test]
815 async fn task_from_row_rejects_invalid_status_and_priority() {
816 let mut conn = SqliteConnection::connect(":memory:")
817 .await
818 .expect("open db");
819 let row = sqlx::query(
820 "SELECT 'TASK000000000001' AS id,
821 '0000000000000000' AS workspace_id,
822 'bad status' AS title,
823 '' AS description,
824 '0000000000000001' AS project_id,
825 'app' AS project_key,
826 'APP' AS project_prefix,
827 'blocked' AS status,
828 'none' AS priority,
829 'unknown' AS source,
830 't' AS created_at,
831 't' AS updated_at,
832 't' AS queue_activity_at,
833 0 AS deleted,
834 0 AS is_epic",
835 )
836 .fetch_one(&mut conn)
837 .await
838 .expect("row");
839 assert_eq!(
840 task_from_row(&row).unwrap_err().to_string(),
841 "error invalid-status input=blocked choices=inbox,backlog,todo,active,done,canceled"
842 );
843
844 let row = sqlx::query(
845 "SELECT 'TASK000000000001' AS id,
846 '0000000000000000' AS workspace_id,
847 'bad priority' AS title,
848 '' AS description,
849 '0000000000000001' AS project_id,
850 'app' AS project_key,
851 'APP' AS project_prefix,
852 'inbox' AS status,
853 'soon' AS priority,
854 't' AS created_at,
855 't' AS updated_at,
856 't' AS queue_activity_at,
857 0 AS deleted,
858 0 AS is_epic",
859 )
860 .fetch_one(&mut conn)
861 .await
862 .expect("row");
863 assert_eq!(
864 task_from_row(&row).unwrap_err().to_string(),
865 "error invalid-priority input=soon choices=none,low,medium,high,urgent"
866 );
867 }
868
869 #[tokio::test]
870 async fn recurrence_migration_enforces_schedule_immutability_and_task_conflict_compatibility() {
871 let (_temp, mut conn) = crate::test_support::test_conn().await;
872 sqlx::query(
873 "INSERT INTO recurrence_series(
874 workspace_id, id, title, description, project_id, priority, initial_status,
875 frequency, interval, weekdays, timezone, start_on, available_local_time,
876 due_policy, state, created_at, updated_at
877 ) VALUES (
878 '0000000000000000', '7KQ9A1X4MV2P8D6R', 'journal', '',
879 '7KQ9A1X4MV2P8D6S', 'none', 'todo', 'daily', 1, '', 'UTC',
880 '2026-07-20', '09:00:00', 'same_day', 'active', 't', 't'
881 )",
882 )
883 .execute(&mut *conn)
884 .await
885 .unwrap();
886
887 sqlx::query(
888 "UPDATE recurrence_series SET title = 'future journal' WHERE id = '7KQ9A1X4MV2P8D6R'",
889 )
890 .execute(&mut *conn)
891 .await
892 .unwrap();
893 let error = sqlx::query(
894 "UPDATE recurrence_series SET frequency = 'weekly', weekdays = 'mon' WHERE id = '7KQ9A1X4MV2P8D6R'",
895 )
896 .execute(&mut *conn)
897 .await
898 .unwrap_err();
899 assert!(
900 error
901 .to_string()
902 .contains("recurrence schedule is immutable")
903 );
904
905 sqlx::query(
906 "INSERT INTO conflicts(task_id, field, local_value, remote_value, remote_change_id, variant_a, variant_b, created_at)
907 VALUES ('7KQ9A1X4MV2P8D6T', 'title', 'a', 'b', 'remote', 'a', 'b', 't')",
908 )
909 .execute(&mut *conn)
910 .await
911 .unwrap();
912 let identity: (String, String) = sqlx::query_as(
913 "SELECT entity_type, entity_id FROM conflicts WHERE task_id = '7KQ9A1X4MV2P8D6T'",
914 )
915 .fetch_one(&mut *conn)
916 .await
917 .unwrap();
918 assert_eq!(
919 identity,
920 ("task".to_string(), "7KQ9A1X4MV2P8D6T".to_string())
921 );
922 }
923
924 #[tokio::test]
925 async fn recurrence_rows_map_through_validated_domain_types() {
926 let mut conn = SqliteConnection::connect(":memory:").await.unwrap();
927 let series_row = sqlx::query(
928 "SELECT '0000000000000000' AS workspace_id,
929 '7KQ9A1X4MV2P8D6R' AS id, 'journal' AS title, '' AS description,
930 '7KQ9A1X4MV2P8D6S' AS project_id, 'high' AS priority,
931 'todo' AS initial_status, 'weekly' AS frequency, 2 AS interval,
932 'mon,fri' AS weekdays, 'Europe/Stockholm' AS timezone,
933 '2026-07-20' AS start_on, '09:30:00' AS available_local_time,
934 'same_day' AS due_policy, 'active' AS state, '' AS stopped_at,
935 'created' AS created_at, 'updated' AS updated_at, 0 AS deleted",
936 )
937 .fetch_one(&mut conn)
938 .await
939 .unwrap();
940 let series = recurrence_series_from_row(&series_row).unwrap();
941 assert_eq!(series.id.as_str(), "7KQ9A1X4MV2P8D6R");
942 assert_eq!(series.rule.interval(), 2);
943 assert_eq!(series.available_local_time.unwrap().to_string(), "09:30:00");
944
945 let occurrence_row = sqlx::query(
946 "SELECT '0000000000000000' AS workspace_id,
947 '7KQ9A1X4MV2P8D6R' AS series_id, '2026-07-20' AS slot_on,
948 '7KQ9A1X4MV2P8D6T' AS task_id, 'completed' AS outcome,
949 'resolved' AS resolved_at, 'change' AS outcome_change_id,
950 'resolved' AS projection_state, '' AS archived_at",
951 )
952 .fetch_one(&mut conn)
953 .await
954 .unwrap();
955 let occurrence = recurrence_occurrence_from_row(&occurrence_row).unwrap();
956 assert_eq!(
957 occurrence.task_id.as_ref().map(|task_id| task_id.as_str()),
958 Some("7KQ9A1X4MV2P8D6T")
959 );
960 assert_eq!(occurrence.outcome, Some(RecurrenceOutcome::Completed));
961 assert_eq!(
962 occurrence.projection_state,
963 RecurrenceProjectionState::Resolved
964 );
965
966 let invalid_row = sqlx::query(
967 "SELECT '0000000000000000' AS workspace_id,
968 '7KQ9A1X4MV2P8D6R' AS id, 'journal' AS title, '' AS description,
969 '7KQ9A1X4MV2P8D6S' AS project_id, 'none' AS priority,
970 'todo' AS initial_status, 'weekly' AS frequency, 1 AS interval,
971 'fri,mon' AS weekdays, 'UTC' AS timezone, '2026-07-20' AS start_on,
972 '' AS available_local_time, 'none' AS due_policy, 'active' AS state,
973 '' AS stopped_at, 't' AS created_at, 't' AS updated_at, 0 AS deleted",
974 )
975 .fetch_one(&mut conn)
976 .await
977 .unwrap();
978 assert!(recurrence_series_from_row(&invalid_row).is_err());
979 }
980
981 #[tokio::test]
982 async fn field_versions_support_task_and_recurrence_series_identity() {
983 let (_temp, mut conn) = crate::test_support::test_conn().await;
984 sqlx::query(
985 "INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at)
986 VALUES ('7KQ9A1X4MV2P8D6T', 'task', '', '7KQ9A1X4MV2P8D6S', 'todo', 'none', 't', 't')",
987 )
988 .execute(&mut *conn)
989 .await
990 .unwrap();
991 set_field_version(&mut conn, "7KQ9A1X4MV2P8D6T", "title", "task-version")
992 .await
993 .unwrap();
994 set_entity_field_version(
995 &mut conn,
996 &crate::workspaces::default_workspace_id(),
997 MutableEntityType::RecurrenceSeries,
998 "7KQ9A1X4MV2P8D6R",
999 "title",
1000 "series-version",
1001 )
1002 .await
1003 .unwrap();
1004
1005 assert_eq!(
1006 field_version(&mut conn, "7KQ9A1X4MV2P8D6T", "title")
1007 .await
1008 .unwrap()
1009 .as_deref(),
1010 Some("task-version")
1011 );
1012 assert_eq!(
1013 entity_field_version(
1014 &mut conn,
1015 &crate::workspaces::default_workspace_id(),
1016 MutableEntityType::RecurrenceSeries,
1017 "7KQ9A1X4MV2P8D6R",
1018 "title",
1019 )
1020 .await
1021 .unwrap()
1022 .as_deref(),
1023 Some("series-version")
1024 );
1025 }
1026}