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