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