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