Skip to main content

aven_core/
db.rs

1use crate::ids::WorkspaceId;
2use std::fs;
3use std::ops::{Deref, DerefMut};
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, bail};
10use chrono::{NaiveDate, NaiveTime};
11use serde_json::Value;
12use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow};
13use sqlx::{Connection as _, Row, Sqlite, SqliteConnection, SqlitePool, Transaction};
14use tokio::sync::{Mutex, OwnedMutexGuard};
15
16use crate::choices::{TaskPriority, TaskSource, TaskStatus};
17use crate::error::CoreError;
18use crate::ids::{new_id, now};
19use crate::recurrence::{
20    RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
21    RecurrenceRule, RecurrenceSeriesState, TimeZoneId, WeekdaySet,
22};
23use crate::types::{
24    MutableEntityType, RecurrenceOccurrence, RecurrencePauseInterval, RecurrenceSeries,
25    RecurrenceSeriesLabel, Task,
26};
27use crate::workspaces::ensure_default_workspace;
28
29static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
30const MIGRATION_BACKUP_KEEP: usize = 20;
31const FILE_DATABASE_CONNECTIONS: u32 = 5;
32
33#[derive(Clone)]
34pub struct Database {
35    pool: SqlitePool,
36    writer: Arc<Mutex<()>>,
37    path: PathBuf,
38    file_identity: Option<PathBuf>,
39}
40
41pub(crate) struct WriterConnection {
42    connection: sqlx::pool::PoolConnection<Sqlite>,
43    _guard: OwnedMutexGuard<()>,
44}
45
46impl Deref for WriterConnection {
47    type Target = SqliteConnection;
48
49    fn deref(&self) -> &Self::Target {
50        &self.connection
51    }
52}
53
54impl DerefMut for WriterConnection {
55    fn deref_mut(&mut self) -> &mut Self::Target {
56        &mut self.connection
57    }
58}
59
60impl Database {
61    pub async fn open(path: &Path) -> Result<Self> {
62        let connection_input = path.to_string_lossy();
63        let options = SqliteConnectOptions::from_str(&connection_input)?;
64        let storage = database_storage(&connection_input, &options);
65        let pool = open_db(path).await?;
66        let file_identity = match storage {
67            DatabaseStorage::InMemory => None,
68            DatabaseStorage::File => {
69                Some(fs::canonicalize(options.get_filename()).with_context(|| {
70                    format!(
71                        "could not resolve database path {}",
72                        options.get_filename().display()
73                    )
74                })?)
75            }
76        };
77        Ok(Self {
78            pool,
79            writer: Arc::new(Mutex::new(())),
80            path: path.to_path_buf(),
81            file_identity,
82        })
83    }
84
85    pub fn path(&self) -> &Path {
86        &self.path
87    }
88
89    #[doc(hidden)]
90    pub fn file_identity(&self) -> Option<&Path> {
91        self.file_identity.as_deref()
92    }
93
94    pub fn latest_schema_version() -> Option<i64> {
95        MIGRATOR.iter().map(|migration| migration.version).max()
96    }
97
98    pub async fn meta(&self, key: &str) -> Result<Option<String>> {
99        let mut conn = self.acquire_reader().await?;
100        get_meta(&mut conn, key).await
101    }
102
103    pub async fn conflict_exists(
104        &self,
105        workspace_id: &WorkspaceId,
106        task_id: &crate::ids::TaskId,
107        field: &str,
108    ) -> Result<bool> {
109        let mut conn = self.acquire_reader().await?;
110        conflict_exists(&mut conn, workspace_id, task_id, field).await
111    }
112
113    pub(crate) async fn acquire_reader(&self) -> Result<sqlx::pool::PoolConnection<Sqlite>> {
114        Ok(self.pool.acquire().await?)
115    }
116
117    pub(crate) async fn acquire_writer(&self) -> Result<WriterConnection> {
118        let guard = self.writer.clone().lock_owned().await;
119        let connection = self.pool.acquire().await?;
120        Ok(WriterConnection {
121            connection,
122            _guard: guard,
123        })
124    }
125}
126
127pub(crate) async fn open_db(path: &Path) -> Result<SqlitePool> {
128    let connection_input = path.to_string_lossy();
129    let mut options = SqliteConnectOptions::from_str(&connection_input)?;
130    let storage = database_storage(&connection_input, &options);
131    let existed_before_open = storage == DatabaseStorage::File && path.exists();
132    if storage == DatabaseStorage::File
133        && let Some(parent) = path.parent()
134    {
135        fs::create_dir_all(parent)
136            .with_context(|| format!("could not create {}", parent.display()))?;
137    }
138    options = options
139        .create_if_missing(true)
140        .foreign_keys(true)
141        .busy_timeout(Duration::from_secs(5));
142    let pool_options = match storage {
143        DatabaseStorage::InMemory => SqlitePoolOptions::new()
144            .min_connections(1)
145            .max_connections(1)
146            .idle_timeout(None)
147            .max_lifetime(None),
148        DatabaseStorage::File => {
149            options = options.journal_mode(SqliteJournalMode::Wal);
150            SqlitePoolOptions::new().max_connections(FILE_DATABASE_CONNECTIONS)
151        }
152    };
153    let pool = pool_options
154        .connect_with(options)
155        .await
156        .with_context(|| format!("could not open {}", path.display()))?;
157    backup_before_pending_migrations(path, existed_before_open, &pool).await?;
158    MIGRATOR.run(&pool).await?;
159    initialize_meta(&pool).await?;
160    let mut conn = pool.acquire().await?;
161    ensure_default_workspace(&mut conn).await?;
162    Ok(pool)
163}
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166enum DatabaseStorage {
167    InMemory,
168    File,
169}
170
171fn database_storage(connection_input: &str, options: &SqliteConnectOptions) -> DatabaseStorage {
172    let connection_input = connection_input
173        .trim_start_matches("sqlite://")
174        .trim_start_matches("sqlite:");
175    let mut database_and_params = connection_input.splitn(2, '?');
176    let database = database_and_params.next().unwrap_or_default();
177    let uses_memory_mode = database_and_params.next().is_some_and(|params| {
178        url::form_urlencoded::parse(params.as_bytes())
179            .any(|(key, value)| key == "mode" && value == "memory")
180    });
181    let filename = options.get_filename();
182
183    if database == ":memory:"
184        || database == "file::memory:"
185        || filename == Path::new(":memory:")
186        || filename == Path::new("file::memory:")
187        || uses_memory_mode
188    {
189        DatabaseStorage::InMemory
190    } else {
191        DatabaseStorage::File
192    }
193}
194
195async fn backup_before_pending_migrations(
196    path: &Path,
197    existed_before_open: bool,
198    pool: &SqlitePool,
199) -> Result<()> {
200    if !migration_backups_enabled() || !existed_before_open || !has_pending_migrations(pool).await?
201    {
202        return Ok(());
203    }
204    let backup_path = migration_backup_path(path)?;
205    let mut conn = pool.acquire().await?;
206    backup_database_with_connection(&mut conn, &backup_path).await?;
207    prune_migration_backups(path)?;
208    Ok(())
209}
210
211fn migration_backups_enabled() -> bool {
212    std::env::var_os("AVEN_DEV_MIGRATION_BACKUPS").is_some()
213}
214
215async fn has_pending_migrations(pool: &SqlitePool) -> Result<bool> {
216    let applied_versions =
217        match sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations")
218            .fetch_all(pool)
219            .await
220        {
221            Ok(versions) => versions,
222            Err(error) => {
223                let Some(db_error) = error.as_database_error() else {
224                    return Err(error.into());
225                };
226                if db_error.code().as_deref() == Some("1") {
227                    return Ok(MIGRATOR.iter().next().is_some());
228                }
229                return Err(error.into());
230            }
231        };
232    Ok(MIGRATOR
233        .iter()
234        .any(|migration| !applied_versions.contains(&migration.version)))
235}
236
237fn migration_backup_path(path: &Path) -> Result<PathBuf> {
238    default_sqlite_backup_path(path, "before-migrate")
239}
240
241pub fn default_backup_path(path: &Path, reason: &str) -> Result<PathBuf> {
242    backup_path_with_extension(path, reason, "aven-backup.tar.zst")
243}
244
245pub fn default_sqlite_backup_path(path: &Path, reason: &str) -> Result<PathBuf> {
246    backup_path_with_extension(path, reason, "sqlite")
247}
248
249fn backup_path_with_extension(path: &Path, reason: &str, extension: &str) -> Result<PathBuf> {
250    let parent = path.parent().unwrap_or_else(|| Path::new("."));
251    let backup_dir = parent.join("backups");
252    fs::create_dir_all(&backup_dir)
253        .with_context(|| format!("could not create {}", backup_dir.display()))?;
254    let stem = path
255        .file_name()
256        .and_then(|name| name.to_str())
257        .unwrap_or("db.sqlite");
258    Ok(backup_dir.join(format!(
259        "{stem}.{reason}-{}.{}",
260        backup_timestamp()?,
261        extension
262    )))
263}
264
265pub async fn backup_database(source: &Path, backup: &Path) -> Result<()> {
266    if !source.is_file() {
267        bail!("could not open source {}", source.display());
268    }
269    if let Some(parent) = backup.parent() {
270        fs::create_dir_all(parent)
271            .with_context(|| format!("could not create {}", parent.display()))?;
272    }
273    let mut conn = SqliteConnection::connect_with(
274        &SqliteConnectOptions::new()
275            .filename(source)
276            .read_only(true)
277            .foreign_keys(true)
278            .busy_timeout(Duration::from_secs(5)),
279    )
280    .await
281    .with_context(|| format!("could not open source {}", source.display()))?;
282    backup_database_with_connection(&mut conn, backup).await
283}
284
285pub fn wal_path(path: &Path) -> PathBuf {
286    PathBuf::from(format!("{}-wal", path.display()))
287}
288
289pub fn shm_path(path: &Path) -> PathBuf {
290    PathBuf::from(format!("{}-shm", path.display()))
291}
292
293pub async fn restore_database_file(target: &Path, source: &Path) -> Result<PathBuf> {
294    validate_sqlite_source(source).await?;
295    let safety = create_restore_safety_backup(target).await?;
296    let staging = target.with_extension("restore-staging");
297    if staging.exists() {
298        fs::remove_file(&staging)
299            .with_context(|| format!("could not remove {}", staging.display()))?;
300    }
301    fs::copy(source, &staging).with_context(|| {
302        format!(
303            "could not copy {} -> {}",
304            source.display(),
305            staging.display()
306        )
307    })?;
308    for sidecar in [wal_path(target), shm_path(target)] {
309        if sidecar.exists() {
310            fs::remove_file(&sidecar)
311                .with_context(|| format!("could not remove {}", sidecar.display()))?;
312        }
313    }
314    fs::rename(&staging, target)
315        .with_context(|| format!("could not replace {}", target.display()))?;
316    Ok(safety)
317}
318
319pub(crate) async fn create_restore_safety_backup(target: &Path) -> Result<PathBuf> {
320    let safety = default_sqlite_backup_path(target, "before-restore")?;
321    if target.exists() {
322        backup_database(target, &safety).await?;
323    } else {
324        SqliteConnection::connect_with(
325            &SqliteConnectOptions::new()
326                .filename(&safety)
327                .create_if_missing(true),
328        )
329        .await
330        .with_context(|| format!("could not create {}", safety.display()))?
331        .close()
332        .await?;
333    }
334    Ok(safety)
335}
336
337async fn validate_sqlite_source(source: &Path) -> Result<()> {
338    let mut conn = sqlx::SqliteConnection::connect_with(
339        &SqliteConnectOptions::new()
340            .filename(source)
341            .read_only(true)
342            .foreign_keys(true),
343    )
344    .await
345    .with_context(|| format!("could not open source {}", source.display()))?;
346    let quick_check: String = sqlx::query_scalar("PRAGMA quick_check")
347        .fetch_one(&mut conn)
348        .await?;
349    if quick_check != "ok" {
350        bail!("error backup-source-corrupt quick_check={quick_check}");
351    }
352    Ok(())
353}
354
355fn backup_timestamp() -> Result<u64> {
356    Ok(SystemTime::now()
357        .duration_since(UNIX_EPOCH)
358        .context("system clock is before unix epoch")?
359        .as_secs())
360}
361
362pub(crate) async fn backup_database_with_connection(
363    conn: &mut SqliteConnection,
364    backup: &Path,
365) -> Result<()> {
366    let parent = backup.parent().unwrap_or_else(|| Path::new("."));
367    fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?;
368    let staging_dir = tempfile::Builder::new()
369        .prefix(".aven-sqlite-backup-")
370        .tempdir_in(parent)
371        .with_context(|| format!("could not create backup staging in {}", parent.display()))?;
372    let staging = staging_dir.path().join("database.sqlite");
373    sqlx::query("VACUUM INTO ?")
374        .bind(staging.display().to_string())
375        .execute(&mut *conn)
376        .await
377        .with_context(|| format!("could not back up database to {}", backup.display()))?;
378    fs::rename(&staging, backup)
379        .with_context(|| format!("could not replace {}", backup.display()))?;
380    Ok(())
381}
382
383fn prune_migration_backups(path: &Path) -> Result<()> {
384    let Some(parent) = path.parent() else {
385        return Ok(());
386    };
387    let backup_dir = parent.join("backups");
388    let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
389        return Ok(());
390    };
391    let prefix = format!("{file_name}.before-migrate-");
392    let mut backups = fs::read_dir(&backup_dir)
393        .with_context(|| format!("could not read {}", backup_dir.display()))?
394        .filter_map(|entry| entry.ok())
395        .filter(|entry| {
396            entry
397                .file_name()
398                .to_str()
399                .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".sqlite"))
400        })
401        .collect::<Vec<_>>();
402    backups.sort_by_key(|entry| entry.file_name());
403    let remove_count = backups.len().saturating_sub(MIGRATION_BACKUP_KEEP);
404    for entry in backups.into_iter().take(remove_count) {
405        let path = entry.path();
406        fs::remove_file(&path).with_context(|| format!("could not remove {}", path.display()))?;
407    }
408    Ok(())
409}
410
411async fn initialize_meta(pool: &SqlitePool) -> Result<()> {
412    let mut conn = pool.acquire().await?;
413    insert_meta_if_missing(&mut conn, "client_id", &new_id()).await?;
414    insert_meta_if_missing(&mut conn, "sync_cursor", "0").await?;
415    insert_meta_if_missing(&mut conn, "local_seq", "0").await?;
416    Ok(())
417}
418
419pub(crate) async fn current_schema_version(conn: &mut SqliteConnection) -> Result<i64> {
420    let version: Option<i64> = sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations")
421        .fetch_one(conn)
422        .await?;
423    Ok(version.unwrap_or(0))
424}
425
426pub(crate) async fn get_meta(conn: &mut SqliteConnection, key: &str) -> Result<Option<String>> {
427    Ok(
428        sqlx::query_scalar!("SELECT value FROM meta WHERE key = ?", key)
429            .fetch_optional(&mut *conn)
430            .await?,
431    )
432}
433
434pub(crate) async fn set_meta(conn: &mut SqliteConnection, key: &str, value: &str) -> Result<()> {
435    sqlx::query!(
436        "INSERT INTO meta(key, value) VALUES (?, ?)
437         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
438        key,
439        value,
440    )
441    .execute(&mut *conn)
442    .await?;
443    Ok(())
444}
445
446pub(crate) async fn begin_immediate(
447    conn: &mut SqliteConnection,
448) -> sqlx::Result<Transaction<'_, Sqlite>> {
449    conn.begin_with("BEGIN IMMEDIATE").await
450}
451
452async fn insert_meta_if_missing(conn: &mut SqliteConnection, key: &str, value: &str) -> Result<()> {
453    sqlx::query!(
454        "INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)",
455        key,
456        value
457    )
458    .execute(&mut *conn)
459    .await?;
460    Ok(())
461}
462
463async fn next_local_seq(conn: &mut SqliteConnection) -> Result<i64> {
464    let seq = get_meta(conn, "local_seq")
465        .await?
466        .unwrap_or_else(|| "0".to_string())
467        .parse::<i64>()?
468        + 1;
469    set_meta(conn, "local_seq", &seq.to_string()).await?;
470    Ok(seq)
471}
472
473pub(crate) async fn insert_change(
474    conn: &mut SqliteConnection,
475    entity_type: &str,
476    entity_id: &str,
477    field: Option<&str>,
478    op_type: &str,
479    payload: Value,
480    base_version: Option<&str>,
481) -> Result<String> {
482    let change_id = new_id();
483    let client_id = get_meta(conn, "client_id")
484        .await?
485        .context("missing client id")?;
486    let local_seq = next_local_seq(conn).await?;
487    let created_at = now();
488    let payload = payload.to_string();
489    sqlx::query!(
490        "INSERT INTO changes(change_id, client_id, local_seq, entity_type, entity_id, field,
491         op_type, payload, base_version, created_at)
492         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
493        change_id,
494        client_id,
495        local_seq,
496        entity_type,
497        entity_id,
498        field,
499        op_type,
500        payload,
501        base_version,
502        created_at,
503    )
504    .execute(&mut *conn)
505    .await?;
506    Ok(change_id)
507}
508
509pub(crate) struct IdentifiedChange<'a> {
510    pub change_id: &'a str,
511    pub entity_type: &'a str,
512    pub entity_id: &'a str,
513    pub field: Option<&'a str>,
514    pub op_type: &'a str,
515    pub payload: Value,
516    pub base_version: Option<&'a str>,
517    pub created_at: &'a str,
518}
519
520pub(crate) async fn insert_change_with_identity(
521    conn: &mut SqliteConnection,
522    change: IdentifiedChange<'_>,
523) -> Result<()> {
524    let IdentifiedChange {
525        change_id,
526        entity_type,
527        entity_id,
528        field,
529        op_type,
530        payload,
531        base_version,
532        created_at,
533    } = change;
534    let payload = payload.to_string();
535    let existing = sqlx::query(
536        "SELECT entity_type, entity_id, field, op_type, payload, base_version, created_at
537         FROM changes WHERE change_id = ?",
538    )
539    .bind(change_id)
540    .fetch_optional(&mut *conn)
541    .await?;
542    if let Some(existing) = existing {
543        let equal = existing.try_get::<String, _>("entity_type")? == entity_type
544            && existing.try_get::<String, _>("entity_id")? == entity_id
545            && existing.try_get::<Option<String>, _>("field")?.as_deref() == field
546            && existing.try_get::<String, _>("op_type")? == op_type
547            && existing.try_get::<String, _>("payload")? == payload
548            && existing
549                .try_get::<Option<String>, _>("base_version")?
550                .as_deref()
551                == base_version
552            && existing.try_get::<String, _>("created_at")? == created_at;
553        if equal {
554            return Ok(());
555        }
556        return Err(CoreError::generation_conflict(format!(
557            "error recurrence-generation-conflict change_id={change_id}"
558        ))
559        .into());
560    }
561
562    let client_id = get_meta(conn, "client_id")
563        .await?
564        .context("missing client id")?;
565    let local_seq = next_local_seq(conn).await?;
566    sqlx::query(
567        "INSERT INTO changes(change_id, client_id, local_seq, entity_type, entity_id, field,
568         op_type, payload, base_version, created_at)
569         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
570    )
571    .bind(change_id)
572    .bind(client_id)
573    .bind(local_seq)
574    .bind(entity_type)
575    .bind(entity_id)
576    .bind(field)
577    .bind(op_type)
578    .bind(payload)
579    .bind(base_version)
580    .bind(created_at)
581    .execute(&mut *conn)
582    .await?;
583    Ok(())
584}
585
586fn optional_task_date(value: String) -> Option<String> {
587    (!value.is_empty()).then_some(value)
588}
589
590pub(crate) fn task_from_row(row: &SqliteRow) -> Result<Task> {
591    Ok(Task {
592        id: row.try_get("id")?,
593        workspace_id: row.try_get("workspace_id")?,
594        title: row.try_get("title")?,
595        description: row.try_get("description")?,
596        project_id: row.try_get("project_id")?,
597        project_key: row.try_get("project_key")?,
598        project_prefix: row.try_get("project_prefix")?,
599        status: TaskStatus::parse(row.try_get::<String, _>("status")?.as_str())?,
600        priority: TaskPriority::parse(row.try_get::<String, _>("priority")?.as_str())?,
601        source: TaskSource::parse(row.try_get::<String, _>("source")?.as_str())?,
602        created_at: row.try_get("created_at")?,
603        updated_at: row.try_get("updated_at")?,
604        queue_activity_at: row.try_get("queue_activity_at")?,
605        available_at: optional_task_date(row.try_get("available_at")?),
606        due_on: optional_task_date(row.try_get("due_on")?),
607        deleted: row.try_get::<i64, _>("deleted")? != 0,
608        is_epic: row.try_get::<i64, _>("is_epic")? != 0,
609    })
610}
611
612pub(crate) fn recurrence_series_from_row(row: &SqliteRow) -> Result<RecurrenceSeries> {
613    let frequency = RecurrenceFrequency::parse(&row.try_get::<String, _>("frequency")?)?;
614    let interval = u32::try_from(row.try_get::<i64, _>("interval")?)
615        .context("recurrence interval must fit u32")?;
616    let weekdays = row
617        .try_get::<String, _>("weekdays")?
618        .parse::<WeekdaySet>()
619        .map_err(anyhow::Error::msg)?;
620    let rule = RecurrenceRule::new(frequency, interval, weekdays)?;
621    let start_on = row
622        .try_get::<String, _>("start_on")?
623        .parse::<NaiveDate>()
624        .context("invalid recurrence start date")?;
625    let available_local_time = optional_text(row.try_get("available_local_time")?)
626        .map(|value| {
627            value
628                .parse::<NaiveTime>()
629                .context("invalid recurrence availability time")
630        })
631        .transpose()?;
632    let initial_status = TaskStatus::parse(&row.try_get::<String, _>("initial_status")?)?;
633    if !initial_status.is_open() {
634        bail!("recurrence initial status must be open");
635    }
636    let state = RecurrenceSeriesState::parse(&row.try_get::<String, _>("state")?)?;
637    let stopped_at = optional_text(row.try_get("stopped_at")?);
638    if matches!(state, RecurrenceSeriesState::Stopped) != stopped_at.is_some() {
639        bail!("recurrence stopped state and stop time must agree");
640    }
641    let deleted = row.try_get::<i64, _>("deleted")?;
642    if !matches!(deleted, 0 | 1) {
643        bail!("recurrence deleted value must be zero or one");
644    }
645    Ok(RecurrenceSeries {
646        workspace_id: row.try_get("workspace_id")?,
647        id: row.try_get::<String, _>("id")?.parse()?,
648        title: row.try_get("title")?,
649        description: row.try_get("description")?,
650        project_id: row.try_get("project_id")?,
651        priority: TaskPriority::parse(&row.try_get::<String, _>("priority")?)?,
652        initial_status,
653        rule,
654        timezone: row
655            .try_get::<String, _>("timezone")?
656            .parse::<TimeZoneId>()?,
657        start_on,
658        available_local_time,
659        due_policy: RecurrenceDuePolicy::parse(&row.try_get::<String, _>("due_policy")?)?,
660        state,
661        stopped_at,
662        created_at: row.try_get("created_at")?,
663        updated_at: row.try_get("updated_at")?,
664        deleted: deleted != 0,
665    })
666}
667
668pub(crate) fn recurrence_series_label_from_row(row: &SqliteRow) -> Result<RecurrenceSeriesLabel> {
669    Ok(RecurrenceSeriesLabel {
670        workspace_id: row.try_get("workspace_id")?,
671        series_id: row.try_get::<String, _>("series_id")?.parse()?,
672        label: row.try_get("label")?,
673    })
674}
675
676pub(crate) fn recurrence_occurrence_from_row(row: &SqliteRow) -> Result<RecurrenceOccurrence> {
677    let task_id = optional_text(row.try_get("task_id")?)
678        .map(|value| value.parse())
679        .transpose()?;
680    let outcome = optional_text(row.try_get("outcome")?)
681        .map(|value| RecurrenceOutcome::parse(&value))
682        .transpose()?;
683    let resolved_at = optional_text(row.try_get("resolved_at")?);
684    let outcome_change_id = optional_text(row.try_get("outcome_change_id")?);
685    let projection_state =
686        RecurrenceProjectionState::parse(&row.try_get::<String, _>("projection_state")?)?;
687    let archived_at = optional_text(row.try_get("archived_at")?);
688    let valid_shape = match projection_state {
689        RecurrenceProjectionState::Projected => {
690            task_id.is_some()
691                && outcome.is_none()
692                && resolved_at.is_none()
693                && outcome_change_id.is_none()
694                && archived_at.is_none()
695        }
696        RecurrenceProjectionState::Resolved => {
697            task_id.is_some()
698                && outcome.is_some()
699                && resolved_at.is_some()
700                && outcome_change_id.is_some()
701                && archived_at.is_none()
702        }
703        RecurrenceProjectionState::Archived => {
704            task_id.is_some()
705                && outcome.is_none()
706                && resolved_at.is_none()
707                && outcome_change_id.is_none()
708                && archived_at.is_some()
709        }
710    };
711    if !valid_shape {
712        bail!("recurrence occurrence fields do not match projection state");
713    }
714    Ok(RecurrenceOccurrence {
715        workspace_id: row.try_get("workspace_id")?,
716        series_id: row.try_get::<String, _>("series_id")?.parse()?,
717        slot_on: row
718            .try_get::<String, _>("slot_on")?
719            .parse::<NaiveDate>()
720            .context("invalid recurrence slot date")?,
721        task_id,
722        outcome,
723        resolved_at,
724        outcome_change_id,
725        projection_state,
726        archived_at,
727    })
728}
729
730pub(crate) fn recurrence_pause_interval_from_row(
731    row: &SqliteRow,
732) -> Result<RecurrencePauseInterval> {
733    let suspended_slot_on = optional_text(row.try_get("suspended_slot_on")?)
734        .map(|value| {
735            value
736                .parse::<NaiveDate>()
737                .context("invalid suspended recurrence slot date")
738        })
739        .transpose()?;
740    let suspended_task_id = optional_text(row.try_get("suspended_task_id")?)
741        .map(|value| value.parse())
742        .transpose()?;
743    let resumed_at = optional_text(row.try_get("resumed_at")?);
744    let resolved_by_change_id = optional_text(row.try_get("resolved_by_change_id")?);
745    if resumed_at.is_some() != resolved_by_change_id.is_some() {
746        bail!("recurrence pause resume time and change must agree");
747    }
748    if suspended_slot_on.is_some() != suspended_task_id.is_some() {
749        bail!("recurrence suspended slot and task must agree");
750    }
751    Ok(RecurrencePauseInterval {
752        workspace_id: row.try_get("workspace_id")?,
753        id: row.try_get("id")?,
754        series_id: row.try_get::<String, _>("series_id")?.parse()?,
755        paused_at: row.try_get("paused_at")?,
756        resumed_at,
757        suspended_slot_on,
758        suspended_task_id,
759        created_by_change_id: row.try_get("created_by_change_id")?,
760        resolved_by_change_id,
761    })
762}
763
764fn optional_text(value: String) -> Option<String> {
765    (!value.is_empty()).then_some(value)
766}
767
768pub(crate) async fn entity_field_version(
769    conn: &mut SqliteConnection,
770    workspace_id: &WorkspaceId,
771    entity_type: MutableEntityType,
772    entity_id: &str,
773    field: &str,
774) -> Result<Option<String>> {
775    Ok(sqlx::query_scalar(
776        "SELECT version FROM field_versions
777         WHERE workspace_id = ? AND entity_type = ? AND entity_id = ? AND field = ?",
778    )
779    .bind(workspace_id)
780    .bind(entity_type.as_str())
781    .bind(entity_id)
782    .bind(field)
783    .fetch_optional(&mut *conn)
784    .await?)
785}
786
787pub(crate) async fn set_entity_field_version(
788    conn: &mut SqliteConnection,
789    workspace_id: &WorkspaceId,
790    entity_type: MutableEntityType,
791    entity_id: &str,
792    field: &str,
793    version: &str,
794) -> Result<()> {
795    sqlx::query(
796        "INSERT INTO field_versions(workspace_id, entity_type, entity_id, field, version)
797         VALUES (?, ?, ?, ?, ?)
798         ON CONFLICT(workspace_id, entity_type, entity_id, field)
799         DO UPDATE SET version = excluded.version",
800    )
801    .bind(workspace_id)
802    .bind(entity_type.as_str())
803    .bind(entity_id)
804    .bind(field)
805    .bind(version)
806    .execute(&mut *conn)
807    .await?;
808    Ok(())
809}
810
811pub(crate) async fn field_version(
812    conn: &mut SqliteConnection,
813    entity_id: &str,
814    field: &str,
815) -> Result<Option<String>> {
816    let workspace_id =
817        sqlx::query_scalar::<_, WorkspaceId>("SELECT workspace_id FROM tasks WHERE id = ? LIMIT 1")
818            .bind(entity_id)
819            .fetch_optional(&mut *conn)
820            .await?;
821    let Some(workspace_id) = workspace_id else {
822        return Ok(None);
823    };
824    entity_field_version(
825        conn,
826        &workspace_id,
827        MutableEntityType::Task,
828        entity_id,
829        field,
830    )
831    .await
832}
833
834pub(crate) async fn set_field_version(
835    conn: &mut SqliteConnection,
836    entity_id: &str,
837    field: &str,
838    version: &str,
839) -> Result<()> {
840    let workspace_id =
841        sqlx::query_scalar::<_, WorkspaceId>("SELECT workspace_id FROM tasks WHERE id = ? LIMIT 1")
842            .bind(entity_id)
843            .fetch_optional(&mut *conn)
844            .await?;
845    if let Some(workspace_id) = workspace_id {
846        set_entity_field_version(
847            conn,
848            &workspace_id,
849            MutableEntityType::Task,
850            entity_id,
851            field,
852            version,
853        )
854        .await?;
855    }
856    Ok(())
857}
858
859pub(crate) async fn entity_conflict_exists(
860    conn: &mut SqliteConnection,
861    workspace_id: &WorkspaceId,
862    entity_type: MutableEntityType,
863    entity_id: &str,
864    field: &str,
865) -> Result<bool> {
866    Ok(sqlx::query_scalar::<_, i64>(
867        "SELECT count(*) FROM conflicts
868         WHERE workspace_id = ? AND entity_type = ? AND entity_id = ?
869         AND field = ? AND resolved = 0 LIMIT 1",
870    )
871    .bind(workspace_id)
872    .bind(entity_type.as_str())
873    .bind(entity_id)
874    .bind(field)
875    .fetch_one(&mut *conn)
876    .await?
877        > 0)
878}
879
880pub(crate) async fn conflict_exists(
881    conn: &mut SqliteConnection,
882    workspace_id: &WorkspaceId,
883    task_id: &crate::ids::TaskId,
884    field: &str,
885) -> Result<bool> {
886    entity_conflict_exists(
887        conn,
888        workspace_id,
889        MutableEntityType::Task,
890        task_id.as_str(),
891        field,
892    )
893    .await
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    const PRIVATE_IN_MEMORY_INPUTS: &[&str] = &[
901        ":memory:",
902        "sqlite::memory:",
903        "sqlite://:memory:",
904        "file::memory:",
905        "sqlite:file::memory:",
906        "sqlite://file::memory:",
907        "%3Amemory%3A",
908        "sqlite:%3Amemory%3A",
909        "sqlite://%3Amemory%3A",
910        "file:%3Amemory%3A",
911        "sqlite:file:%3Amemory%3A",
912        "sqlite://file:%3Amemory%3A",
913        "file::memory:?cache=private",
914        "sqlite://?mode=memory&cache=private",
915        "named?mode=memory&cache=private",
916        "sqlite://named?mode=memory&cache=private",
917        "sqlite://named?cache=private&mode=memory",
918        "sqlite://named?mode=mem%6Fry&cache=private",
919    ];
920
921    #[tokio::test]
922    async fn wal_readers_progress_while_writes_remain_serialized() {
923        let temp = tempfile::tempdir().unwrap();
924        let database = Database::open(&temp.path().join("concurrency.sqlite"))
925            .await
926            .unwrap();
927        let mut writer = database.acquire_writer().await.unwrap();
928        let mut tx = begin_immediate(&mut writer).await.unwrap();
929        set_meta(&mut tx, "local_seq", "1").await.unwrap();
930
931        let reader_database = database.clone();
932        let reader = tokio::spawn(async move {
933            let mut conn = reader_database.acquire_reader().await.unwrap();
934            get_meta(&mut conn, "local_seq").await.unwrap()
935        });
936        let observed = tokio::time::timeout(Duration::from_secs(1), reader)
937            .await
938            .expect("reader should not wait for the writer")
939            .unwrap();
940        assert_eq!(observed.as_deref(), Some("0"));
941
942        let second_writer_database = database.clone();
943        let second_writer = tokio::spawn(async move {
944            let mut conn = second_writer_database.acquire_writer().await.unwrap();
945            let mut tx = begin_immediate(&mut conn).await.unwrap();
946            set_meta(&mut tx, "local_seq", "2").await.unwrap();
947            tx.commit().await.unwrap();
948        });
949        tokio::task::yield_now().await;
950        assert!(!second_writer.is_finished());
951
952        tx.commit().await.unwrap();
953        drop(writer);
954        tokio::time::timeout(Duration::from_secs(1), second_writer)
955            .await
956            .expect("second writer should proceed after the first commits")
957            .unwrap();
958        assert_eq!(
959            database.meta("local_seq").await.unwrap().as_deref(),
960            Some("2")
961        );
962    }
963
964    #[test]
965    fn database_storage_follows_sqlx_connection_input_semantics() {
966        for &input in PRIVATE_IN_MEMORY_INPUTS {
967            let options = SqliteConnectOptions::from_str(input).unwrap();
968            assert_eq!(
969                database_storage(input, &options),
970                DatabaseStorage::InMemory,
971                "{input}"
972            );
973        }
974
975        for input in [
976            "mode=memory.sqlite",
977            "ordinary-file::memory:.sqlite",
978            "sqlite::memory:.sqlite",
979            "/tmp/directory-mode=memory/database.sqlite",
980        ] {
981            let options = SqliteConnectOptions::from_str(input).unwrap();
982            assert_eq!(
983                database_storage(input, &options),
984                DatabaseStorage::File,
985                "{input}"
986            );
987        }
988    }
989
990    #[tokio::test]
991    async fn database_retains_canonical_file_identity() {
992        let temp = tempfile::tempdir().unwrap();
993        let path = temp.path().join("identity.sqlite");
994        let database = Database::open(&path).await.unwrap();
995
996        assert_eq!(
997            database.file_identity(),
998            Some(fs::canonicalize(&path).unwrap().as_path())
999        );
1000    }
1001
1002    #[tokio::test]
1003    async fn in_memory_databases_have_no_file_identity() {
1004        for &input in PRIVATE_IN_MEMORY_INPUTS {
1005            let database = Database::open(Path::new(input)).await.unwrap();
1006            assert_eq!(database.file_identity(), None, "{input}");
1007        }
1008    }
1009
1010    #[tokio::test]
1011    async fn private_in_memory_inputs_keep_one_connection_and_one_schema() {
1012        for &input in PRIVATE_IN_MEMORY_INPUTS {
1013            let pool = open_db(Path::new(input)).await.unwrap();
1014            let pool_options = pool.options();
1015            assert_eq!(pool_options.get_min_connections(), 1, "{input}");
1016            assert_eq!(pool_options.get_max_connections(), 1, "{input}");
1017            assert_eq!(pool_options.get_idle_timeout(), None, "{input}");
1018            assert_eq!(pool_options.get_max_lifetime(), None, "{input}");
1019            assert_eq!(pool.size(), 1, "{input}");
1020            let mut first = pool.acquire().await.unwrap();
1021            let second_pool = pool.clone();
1022            let mut second = tokio::spawn(async move {
1023                let mut connection = second_pool.acquire().await.unwrap();
1024                sqlx::query_scalar::<_, i64>(
1025                    "SELECT count(*) FROM sqlite_schema WHERE name = 'acquisition_visibility'",
1026                )
1027                .fetch_one(&mut *connection)
1028                .await
1029                .unwrap()
1030            });
1031            assert!(
1032                tokio::time::timeout(Duration::from_millis(50), &mut second)
1033                    .await
1034                    .is_err(),
1035                "{input} opened another connection"
1036            );
1037
1038            sqlx::query("CREATE TABLE acquisition_visibility(id INTEGER PRIMARY KEY)")
1039                .execute(&mut *first)
1040                .await
1041                .unwrap();
1042            drop(first);
1043
1044            let visible = tokio::time::timeout(Duration::from_secs(1), second)
1045                .await
1046                .unwrap()
1047                .unwrap();
1048            assert_eq!(visible, 1, "{input}");
1049        }
1050    }
1051
1052    #[tokio::test]
1053    async fn filesystem_lookalike_keeps_wal_and_concurrent_connections() {
1054        let temp = tempfile::tempdir().unwrap();
1055        let path = temp
1056            .path()
1057            .join("ordinary-file::memory:-mode=memory.sqlite");
1058        let pool = open_db(&path).await.unwrap();
1059        let first = pool.acquire().await.unwrap();
1060        let mut second = tokio::time::timeout(Duration::from_secs(1), pool.acquire())
1061            .await
1062            .expect("file pool should allow a concurrent acquisition")
1063            .unwrap();
1064        let journal_mode: String = sqlx::query_scalar("PRAGMA journal_mode")
1065            .fetch_one(&mut *second)
1066            .await
1067            .unwrap();
1068
1069        assert_eq!(
1070            pool.options().get_max_connections(),
1071            FILE_DATABASE_CONNECTIONS
1072        );
1073        assert_eq!(journal_mode, "wal");
1074        drop(first);
1075        drop(second);
1076        pool.close().await;
1077    }
1078
1079    #[tokio::test]
1080    async fn in_process_backup_captures_wal_and_replaces_destination() {
1081        let temp = tempfile::tempdir().unwrap();
1082        let source = temp.path().join("source.sqlite");
1083        let backup = temp.path().join("backup.sqlite");
1084        let database = Database::open(&source).await.unwrap();
1085        let mut writer = database.acquire_writer().await.unwrap();
1086        let mut tx = begin_immediate(&mut writer).await.unwrap();
1087        set_meta(&mut tx, "backup-test", "first").await.unwrap();
1088        tx.commit().await.unwrap();
1089        drop(writer);
1090        assert!(wal_path(&source).exists());
1091
1092        fs::write(&backup, b"existing destination").unwrap();
1093        backup_database(&source, &backup).await.unwrap();
1094        let mut backup_conn = SqliteConnection::connect_with(
1095            &SqliteConnectOptions::new()
1096                .filename(&backup)
1097                .read_only(true),
1098        )
1099        .await
1100        .unwrap();
1101        assert_eq!(
1102            get_meta(&mut backup_conn, "backup-test")
1103                .await
1104                .unwrap()
1105                .as_deref(),
1106            Some("first")
1107        );
1108        drop(backup_conn);
1109
1110        let mut writer = database.acquire_writer().await.unwrap();
1111        set_meta(&mut writer, "backup-test", "second")
1112            .await
1113            .unwrap();
1114        drop(writer);
1115        backup_database(&source, &backup).await.unwrap();
1116        let mut backup_conn = SqliteConnection::connect_with(
1117            &SqliteConnectOptions::new()
1118                .filename(&backup)
1119                .read_only(true),
1120        )
1121        .await
1122        .unwrap();
1123        assert_eq!(
1124            get_meta(&mut backup_conn, "backup-test")
1125                .await
1126                .unwrap()
1127                .as_deref(),
1128            Some("second")
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn in_process_backup_rejects_missing_source() {
1134        let temp = tempfile::tempdir().unwrap();
1135        let backup = temp.path().join("backup.sqlite");
1136        let error = backup_database(&temp.path().join("missing.sqlite"), &backup)
1137            .await
1138            .unwrap_err();
1139        assert!(error.to_string().contains("could not open source"));
1140        assert!(!backup.exists());
1141    }
1142
1143    #[tokio::test]
1144    async fn restore_replaces_sidecars_and_preserves_safety_copy() {
1145        let temp = tempfile::tempdir().unwrap();
1146        let target = temp.path().join("target.sqlite");
1147        let source = temp.path().join("source.sqlite");
1148        let target_database = Database::open(&target).await.unwrap();
1149        let source_database = Database::open(&source).await.unwrap();
1150        let mut target_writer = target_database.acquire_writer().await.unwrap();
1151        set_meta(&mut target_writer, "restore-test", "target")
1152            .await
1153            .unwrap();
1154        drop(target_writer);
1155        let mut source_writer = source_database.acquire_writer().await.unwrap();
1156        set_meta(&mut source_writer, "restore-test", "source")
1157            .await
1158            .unwrap();
1159        drop(source_writer);
1160        target_database.pool.close().await;
1161        source_database.pool.close().await;
1162        fs::write(wal_path(&target), b"stale wal").unwrap();
1163        fs::write(shm_path(&target), b"stale shm").unwrap();
1164
1165        let safety = restore_database_file(&target, &source).await.unwrap();
1166        assert!(!wal_path(&target).exists());
1167        assert!(!shm_path(&target).exists());
1168
1169        let mut restored = SqliteConnection::connect_with(
1170            &SqliteConnectOptions::new()
1171                .filename(&target)
1172                .read_only(true),
1173        )
1174        .await
1175        .unwrap();
1176        assert_eq!(
1177            get_meta(&mut restored, "restore-test")
1178                .await
1179                .unwrap()
1180                .as_deref(),
1181            Some("source")
1182        );
1183        let mut preserved = SqliteConnection::connect_with(
1184            &SqliteConnectOptions::new()
1185                .filename(&safety)
1186                .read_only(true),
1187        )
1188        .await
1189        .unwrap();
1190        assert_eq!(
1191            get_meta(&mut preserved, "restore-test")
1192                .await
1193                .unwrap()
1194                .as_deref(),
1195            Some("target")
1196        );
1197    }
1198
1199    #[tokio::test]
1200    async fn task_from_row_maps_empty_dates_to_absence() {
1201        let mut conn = SqliteConnection::connect(":memory:")
1202            .await
1203            .expect("open db");
1204        let row = sqlx::query(
1205            "SELECT 'TASK000000000001' AS id,
1206                    '0000000000000000' AS workspace_id,
1207                    'optional dates' AS title,
1208                    '' AS description,
1209                    '0000000000000001' AS project_id,
1210                    'app' AS project_key,
1211                    'APP' AS project_prefix,
1212                    'todo' AS status,
1213                    'none' AS priority,
1214                    'unknown' AS source,
1215                    't' AS created_at,
1216                    't' AS updated_at,
1217                    't' AS queue_activity_at,
1218                    '' AS available_at,
1219                    '' AS due_on,
1220                    0 AS deleted,
1221                    0 AS is_epic",
1222        )
1223        .fetch_one(&mut conn)
1224        .await
1225        .expect("row");
1226
1227        let task = task_from_row(&row).unwrap();
1228
1229        assert_eq!(task.available_at, None);
1230        assert_eq!(task.due_on, None);
1231    }
1232
1233    #[test]
1234    fn task_date_boundary_preserves_present_values() {
1235        assert_eq!(
1236            optional_task_date("2099-01-01T00:00:00Z".to_string()).as_deref(),
1237            Some("2099-01-01T00:00:00Z")
1238        );
1239        assert_eq!(
1240            optional_task_date("2099-01-01".to_string()).as_deref(),
1241            Some("2099-01-01")
1242        );
1243    }
1244
1245    #[tokio::test]
1246    async fn task_from_row_rejects_invalid_status_and_priority() {
1247        let mut conn = SqliteConnection::connect(":memory:")
1248            .await
1249            .expect("open db");
1250        let row = sqlx::query(
1251            "SELECT 'TASK000000000001' AS id,
1252                    '0000000000000000' AS workspace_id,
1253                    'bad status' AS title,
1254                    '' AS description,
1255                    '0000000000000001' AS project_id,
1256                    'app' AS project_key,
1257                    'APP' AS project_prefix,
1258                    'blocked' AS status,
1259                    'none' AS priority,
1260                    'unknown' AS source,
1261                    't' AS created_at,
1262                    't' AS updated_at,
1263                    't' AS queue_activity_at,
1264                    0 AS deleted,
1265                    0 AS is_epic",
1266        )
1267        .fetch_one(&mut conn)
1268        .await
1269        .expect("row");
1270        assert_eq!(
1271            task_from_row(&row).unwrap_err().to_string(),
1272            "error invalid-status input=blocked choices=inbox,backlog,todo,active,done,canceled"
1273        );
1274
1275        let row = sqlx::query(
1276            "SELECT 'TASK000000000001' AS id,
1277                    '0000000000000000' AS workspace_id,
1278                    'bad priority' AS title,
1279                    '' AS description,
1280                    '0000000000000001' AS project_id,
1281                    'app' AS project_key,
1282                    'APP' AS project_prefix,
1283                    'inbox' AS status,
1284                    'soon' AS priority,
1285                    't' AS created_at,
1286                    't' AS updated_at,
1287                    't' AS queue_activity_at,
1288                    0 AS deleted,
1289                    0 AS is_epic",
1290        )
1291        .fetch_one(&mut conn)
1292        .await
1293        .expect("row");
1294        assert_eq!(
1295            task_from_row(&row).unwrap_err().to_string(),
1296            "error invalid-priority input=soon choices=none,low,medium,high,urgent"
1297        );
1298    }
1299
1300    #[tokio::test]
1301    async fn recurrence_migration_enforces_schedule_immutability_and_task_conflict_compatibility() {
1302        let (_temp, mut conn) = crate::test_support::test_conn().await;
1303        sqlx::query(
1304            "INSERT INTO recurrence_series(
1305                workspace_id, id, title, description, project_id, priority, initial_status,
1306                frequency, interval, weekdays, timezone, start_on, available_local_time,
1307                due_policy, state, created_at, updated_at
1308             ) VALUES (
1309                '0000000000000000', '7KQ9A1X4MV2P8D6R', 'journal', '',
1310                '7KQ9A1X4MV2P8D6S', 'none', 'todo', 'daily', 1, '', 'UTC',
1311                '2026-07-20', '09:00:00', 'same_day', 'active', 't', 't'
1312             )",
1313        )
1314        .execute(&mut *conn)
1315        .await
1316        .unwrap();
1317
1318        sqlx::query(
1319            "INSERT INTO recurrence_series(
1320                workspace_id, id, title, description, project_id, priority, initial_status,
1321                frequency, interval, weekdays, timezone, start_on, available_local_time,
1322                due_policy, state, created_at, updated_at
1323             ) VALUES
1324                ('0000000000000000', '7KQ9A1X4MV2P8D7A', 'days', '',
1325                 '7KQ9A1X4MV2P8D6S', 'none', 'todo', 'daily', 3, '', 'UTC',
1326                 '2026-07-20', '', 'same_day', 'active', 't', 't'),
1327                ('0000000000000000', '7KQ9A1X4MV2P8D7B', 'months', '',
1328                 '7KQ9A1X4MV2P8D6S', 'none', 'todo', 'monthly', 3, '', 'UTC',
1329                 '2026-07-20', '', 'same_day', 'active', 't', 't'),
1330                ('0000000000000000', '7KQ9A1X4MV2P8D7C', 'years', '',
1331                 '7KQ9A1X4MV2P8D6S', 'none', 'todo', 'yearly', 2, '', 'UTC',
1332                 '2026-07-20', '', 'same_day', 'active', 't', 't')",
1333        )
1334        .execute(&mut *conn)
1335        .await
1336        .unwrap();
1337        for invalid in [
1338            "INSERT INTO recurrence_series(workspace_id,id,title,description,project_id,priority,initial_status,frequency,interval,weekdays,timezone,start_on,available_local_time,due_policy,state,created_at,updated_at) VALUES ('0000000000000000','7KQ9A1X4MV2P8D7D','bad','','7KQ9A1X4MV2P8D6S','none','todo','daily',0,'','UTC','2026-07-20','','same_day','active','t','t')",
1339            "INSERT INTO recurrence_series(workspace_id,id,title,description,project_id,priority,initial_status,frequency,interval,weekdays,timezone,start_on,available_local_time,due_policy,state,created_at,updated_at) VALUES ('0000000000000000','7KQ9A1X4MV2P8D7E','bad','','7KQ9A1X4MV2P8D6S','none','todo','monthly',2,'mon','UTC','2026-07-20','','same_day','active','t','t')",
1340            "INSERT INTO recurrence_series(workspace_id,id,title,description,project_id,priority,initial_status,frequency,interval,weekdays,timezone,start_on,available_local_time,due_policy,state,created_at,updated_at) VALUES ('0000000000000000','7KQ9A1X4MV2P8D7F','bad','','7KQ9A1X4MV2P8D6S','none','todo','weekly',2,'','UTC','2026-07-20','','same_day','active','t','t')",
1341        ] {
1342            assert!(sqlx::query(invalid).execute(&mut *conn).await.is_err());
1343        }
1344
1345        sqlx::query(
1346            "UPDATE recurrence_series SET title = 'future journal' WHERE id = '7KQ9A1X4MV2P8D6R'",
1347        )
1348        .execute(&mut *conn)
1349        .await
1350        .unwrap();
1351        let error = sqlx::query(
1352            "UPDATE recurrence_series SET frequency = 'weekly', weekdays = 'mon' WHERE id = '7KQ9A1X4MV2P8D6R'",
1353        )
1354        .execute(&mut *conn)
1355        .await
1356        .unwrap_err();
1357        assert!(
1358            error
1359                .to_string()
1360                .contains("recurrence schedule is immutable")
1361        );
1362
1363        sqlx::query(
1364            "INSERT INTO conflicts(task_id, field, local_value, remote_value, remote_change_id, variant_a, variant_b, created_at)
1365             VALUES ('7KQ9A1X4MV2P8D6T', 'title', 'a', 'b', 'remote', 'a', 'b', 't')",
1366        )
1367        .execute(&mut *conn)
1368        .await
1369        .unwrap();
1370        let identity: (String, String) = sqlx::query_as(
1371            "SELECT entity_type, entity_id FROM conflicts WHERE task_id = '7KQ9A1X4MV2P8D6T'",
1372        )
1373        .fetch_one(&mut *conn)
1374        .await
1375        .unwrap();
1376        assert_eq!(
1377            identity,
1378            ("task".to_string(), "7KQ9A1X4MV2P8D6T".to_string())
1379        );
1380    }
1381
1382    #[tokio::test]
1383    async fn recurrence_rows_map_through_validated_domain_types() {
1384        let mut conn = SqliteConnection::connect(":memory:").await.unwrap();
1385        let series_row = sqlx::query(
1386            "SELECT '0000000000000000' AS workspace_id,
1387                    '7KQ9A1X4MV2P8D6R' AS id, 'journal' AS title, '' AS description,
1388                    '7KQ9A1X4MV2P8D6S' AS project_id, 'high' AS priority,
1389                    'todo' AS initial_status, 'weekly' AS frequency, 2 AS interval,
1390                    'mon,fri' AS weekdays, 'Europe/Stockholm' AS timezone,
1391                    '2026-07-20' AS start_on, '09:30:00' AS available_local_time,
1392                    'same_day' AS due_policy, 'active' AS state, '' AS stopped_at,
1393                    'created' AS created_at, 'updated' AS updated_at, 0 AS deleted",
1394        )
1395        .fetch_one(&mut conn)
1396        .await
1397        .unwrap();
1398        let series = recurrence_series_from_row(&series_row).unwrap();
1399        assert_eq!(series.id.as_str(), "7KQ9A1X4MV2P8D6R");
1400        assert_eq!(series.rule.interval(), 2);
1401        assert_eq!(series.available_local_time.unwrap().to_string(), "09:30:00");
1402
1403        let occurrence_row = sqlx::query(
1404            "SELECT '0000000000000000' AS workspace_id,
1405                    '7KQ9A1X4MV2P8D6R' AS series_id, '2026-07-20' AS slot_on,
1406                    '7KQ9A1X4MV2P8D6T' AS task_id, 'completed' AS outcome,
1407                    'resolved' AS resolved_at, 'change' AS outcome_change_id,
1408                    'resolved' AS projection_state, '' AS archived_at",
1409        )
1410        .fetch_one(&mut conn)
1411        .await
1412        .unwrap();
1413        let occurrence = recurrence_occurrence_from_row(&occurrence_row).unwrap();
1414        assert_eq!(
1415            occurrence.task_id.as_ref().map(|task_id| task_id.as_str()),
1416            Some("7KQ9A1X4MV2P8D6T")
1417        );
1418        assert_eq!(occurrence.outcome, Some(RecurrenceOutcome::Completed));
1419        assert_eq!(
1420            occurrence.projection_state,
1421            RecurrenceProjectionState::Resolved
1422        );
1423
1424        let invalid_row = sqlx::query(
1425            "SELECT '0000000000000000' AS workspace_id,
1426                    '7KQ9A1X4MV2P8D6R' AS id, 'journal' AS title, '' AS description,
1427                    '7KQ9A1X4MV2P8D6S' AS project_id, 'none' AS priority,
1428                    'todo' AS initial_status, 'weekly' AS frequency, 1 AS interval,
1429                    'fri,mon' AS weekdays, 'UTC' AS timezone, '2026-07-20' AS start_on,
1430                    '' AS available_local_time, 'none' AS due_policy, 'active' AS state,
1431                    '' AS stopped_at, 't' AS created_at, 't' AS updated_at, 0 AS deleted",
1432        )
1433        .fetch_one(&mut conn)
1434        .await
1435        .unwrap();
1436        assert!(recurrence_series_from_row(&invalid_row).is_err());
1437    }
1438
1439    #[tokio::test]
1440    async fn field_versions_support_task_and_recurrence_series_identity() {
1441        let (_temp, mut conn) = crate::test_support::test_conn().await;
1442        sqlx::query(
1443            "INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at)
1444             VALUES ('7KQ9A1X4MV2P8D6T', 'task', '', '7KQ9A1X4MV2P8D6S', 'todo', 'none', 't', 't')",
1445        )
1446        .execute(&mut *conn)
1447        .await
1448        .unwrap();
1449        set_field_version(&mut conn, "7KQ9A1X4MV2P8D6T", "title", "task-version")
1450            .await
1451            .unwrap();
1452        set_entity_field_version(
1453            &mut conn,
1454            &crate::workspaces::default_workspace_id(),
1455            MutableEntityType::RecurrenceSeries,
1456            "7KQ9A1X4MV2P8D6R",
1457            "title",
1458            "series-version",
1459        )
1460        .await
1461        .unwrap();
1462
1463        assert_eq!(
1464            field_version(&mut conn, "7KQ9A1X4MV2P8D6T", "title")
1465                .await
1466                .unwrap()
1467                .as_deref(),
1468            Some("task-version")
1469        );
1470        assert_eq!(
1471            entity_field_version(
1472                &mut conn,
1473                &crate::workspaces::default_workspace_id(),
1474                MutableEntityType::RecurrenceSeries,
1475                "7KQ9A1X4MV2P8D6R",
1476                "title",
1477            )
1478            .await
1479            .unwrap()
1480            .as_deref(),
1481            Some("series-version")
1482        );
1483    }
1484}