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 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}