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