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