Skip to main content

aven_core/
db.rs

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