1use std::collections::{BTreeMap, BTreeSet};
2
3use anyhow::Result;
4
5use crate::db::{Database, begin_immediate, get_meta, set_meta};
6use crate::ids::WorkspaceId;
7
8const IOS_QUEUE_WORKSPACE_META_KEY: &str = "ios_queue_workspace_id";
9const ONBOARDING_META_KEY: &str = "tui_onboarding_version";
10const SYNC_ACCESS_REFUSED_AT_META_KEY: &str = "sync_access_refused_at";
11const SYNC_VAULT_DELETED_AT_META_KEY: &str = "sync_vault_deleted_at";
13const FIRST_LAUNCH_ONBOARDING_VERSION: u32 = 1;
14const TRIAGE_REVIEWS_META_KEY: &str = "tui_triage_reviews";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SyncAccessRefusal {
18 pub at: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SyncVaultDeletion {
23 pub at: String,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28pub struct DaemonSyncState {
29 pub last_success_at: Option<i64>,
31 pub error: Option<DaemonSyncError>,
32 pub next_retry_at: Option<i64>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub struct DaemonSyncError {
38 pub code: String,
39 pub message: String,
40 pub since: i64,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum OnboardingStatus {
46 Due,
47 Complete,
48 Established,
49}
50
51impl Database {
52 pub async fn daemon_sync_state(&self) -> Result<DaemonSyncState> {
53 self.meta("sync_daemon_health")
54 .await?
55 .map(|value| serde_json::from_str(&value).map_err(Into::into))
56 .unwrap_or_else(|| Ok(DaemonSyncState::default()))
57 }
58
59 pub async fn record_daemon_sync_round(
62 &self,
63 at: i64,
64 error: Option<(&str, &str, i64)>,
65 ) -> Result<()> {
66 let previous = self.daemon_sync_state().await?;
67 let mut state = previous.clone();
68 if let Some((code, message, retry_at)) = error {
69 let since = previous
70 .error
71 .as_ref()
72 .filter(|error| error.code == code)
73 .map_or(at, |error| error.since);
74 state.error = Some(DaemonSyncError {
75 code: code.to_string(),
76 message: message.to_string(),
77 since,
78 });
79 state.next_retry_at = Some(retry_at);
80 } else {
81 state.last_success_at = Some(at);
82 state.error = None;
83 state.next_retry_at = None;
84 }
85 if state != previous {
86 let mut conn = self.acquire_writer().await?;
87 set_meta(
88 &mut conn,
89 "sync_daemon_health",
90 &serde_json::to_string(&state)?,
91 )
92 .await?;
93 }
94 Ok(())
95 }
96
97 pub async fn sync_access_refusal(&self) -> Result<Option<SyncAccessRefusal>> {
99 Ok(self
100 .meta(SYNC_ACCESS_REFUSED_AT_META_KEY)
101 .await?
102 .map(|at| SyncAccessRefusal { at }))
103 }
104
105 pub async fn record_sync_access_refusal(&self) -> Result<SyncAccessRefusal> {
107 let at = crate::ids::now();
108 let mut conn = self.acquire_writer().await?;
109 set_meta(&mut conn, SYNC_ACCESS_REFUSED_AT_META_KEY, &at).await?;
110 Ok(SyncAccessRefusal { at })
111 }
112
113 pub async fn clear_sync_access_refusal(&self) -> Result<()> {
115 let mut conn = self.acquire_writer().await?;
116 sqlx::query("DELETE FROM meta WHERE key = ?")
117 .bind(SYNC_ACCESS_REFUSED_AT_META_KEY)
118 .execute(&mut *conn)
119 .await?;
120 Ok(())
121 }
122
123 pub async fn sync_vault_deletion(&self) -> Result<Option<SyncVaultDeletion>> {
125 Ok(self
126 .meta(SYNC_VAULT_DELETED_AT_META_KEY)
127 .await?
128 .map(|at| SyncVaultDeletion { at }))
129 }
130
131 pub async fn record_sync_vault_deletion(&self) -> Result<SyncVaultDeletion> {
134 let at = crate::ids::now();
135 let mut conn = self.acquire_writer().await?;
136 set_meta(&mut conn, SYNC_VAULT_DELETED_AT_META_KEY, &at).await?;
137 Ok(SyncVaultDeletion { at })
138 }
139
140 pub async fn clear_sync_vault_deletion(&self) -> Result<()> {
141 let mut conn = self.acquire_writer().await?;
142 sqlx::query("DELETE FROM meta WHERE key = ?")
143 .bind(SYNC_VAULT_DELETED_AT_META_KEY)
144 .execute(&mut *conn)
145 .await?;
146 Ok(())
147 }
148
149 pub async fn restore_queue_workspace(&self) -> Result<crate::workspaces::Workspace> {
150 let mut conn = self.acquire_writer().await?;
151 let mut tx = begin_immediate(&mut conn).await?;
152 let workspaces = crate::workspaces::list_workspaces(&mut tx).await?;
153 let stored_id = get_meta(&mut tx, IOS_QUEUE_WORKSPACE_META_KEY)
154 .await?
155 .and_then(|value| value.parse::<WorkspaceId>().ok());
156 let selected = stored_id
157 .and_then(|id| workspaces.iter().find(|workspace| workspace.id == id))
158 .or_else(|| {
159 workspaces.iter().find(|workspace| {
160 workspace.id.as_str() == crate::workspaces::DEFAULT_WORKSPACE_ID
161 })
162 })
163 .cloned()
164 .ok_or_else(|| anyhow::anyhow!("no available workspace"))?;
165 set_meta(&mut tx, IOS_QUEUE_WORKSPACE_META_KEY, selected.id.as_str()).await?;
166 tx.commit().await?;
167 Ok(selected)
168 }
169
170 pub async fn select_queue_workspace(
171 &self,
172 workspace_id: &WorkspaceId,
173 ) -> Result<crate::workspaces::Workspace> {
174 let mut conn = self.acquire_writer().await?;
175 let mut tx = begin_immediate(&mut conn).await?;
176 let workspace = crate::workspaces::workspace_for_id(&mut tx, workspace_id).await?;
177 set_meta(&mut tx, IOS_QUEUE_WORKSPACE_META_KEY, workspace.id.as_str()).await?;
178 tx.commit().await?;
179 Ok(workspace)
180 }
181
182 pub async fn onboarding_status(&self) -> Result<OnboardingStatus> {
183 let mut conn = self.acquire_reader().await?;
184 let marker = get_meta(&mut conn, ONBOARDING_META_KEY).await?;
185 if marker_version(marker.as_deref())
186 .is_some_and(|version| version >= FIRST_LAUNCH_ONBOARDING_VERSION)
187 {
188 return Ok(OnboardingStatus::Complete);
189 }
190
191 let has_tasks: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks)")
192 .fetch_one(&mut *conn)
193 .await?;
194 Ok(if has_tasks {
195 OnboardingStatus::Established
196 } else {
197 OnboardingStatus::Due
198 })
199 }
200
201 pub async fn complete_onboarding(&self) -> Result<()> {
202 let mut conn = self.acquire_writer().await?;
203 let mut tx = begin_immediate(&mut conn).await?;
204 let marker = get_meta(&mut tx, ONBOARDING_META_KEY).await?;
205 if marker_version(marker.as_deref())
206 .is_none_or(|version| version < FIRST_LAUNCH_ONBOARDING_VERSION)
207 {
208 set_meta(
209 &mut tx,
210 ONBOARDING_META_KEY,
211 &FIRST_LAUNCH_ONBOARDING_VERSION.to_string(),
212 )
213 .await?;
214 }
215 tx.commit().await?;
216 Ok(())
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
223pub struct ReminderLogEntry {
224 pub kind: String,
225 pub task_id: String,
226 pub trigger: String,
227 pub outcome: String,
228}
229
230impl Database {
231 pub async fn reminder_log(&self) -> Result<Vec<ReminderLogEntry>> {
232 let mut conn = self.acquire_reader().await?;
233 Ok(sqlx::query_as::<_, ReminderLogEntry>(
234 "SELECT kind, task_id, trigger, outcome FROM local_reminder_log",
235 )
236 .fetch_all(&mut *conn)
237 .await?)
238 }
239
240 pub async fn record_reminders(&self, entries: &[ReminderLogEntry]) -> Result<()> {
243 if entries.is_empty() {
244 return Ok(());
245 }
246 let recorded_at = crate::ids::now();
247 let mut conn = self.acquire_writer().await?;
248 let mut tx = begin_immediate(&mut conn).await?;
249 for entry in entries {
250 sqlx::query(
251 "INSERT INTO local_reminder_log(kind, task_id, trigger, outcome, recorded_at)
252 VALUES (?, ?, ?, ?, ?)
253 ON CONFLICT(kind, task_id, trigger)
254 DO UPDATE SET outcome = excluded.outcome, recorded_at = excluded.recorded_at",
255 )
256 .bind(&entry.kind)
257 .bind(&entry.task_id)
258 .bind(&entry.trigger)
259 .bind(&entry.outcome)
260 .bind(&recorded_at)
261 .execute(&mut *tx)
262 .await?;
263 }
264 tx.commit().await?;
265 Ok(())
266 }
267}
268
269fn marker_version(value: Option<&str>) -> Option<u32> {
270 value?.trim().parse().ok()
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
275pub enum SidebarSection {
276 Views,
277 Workspaces,
278 Projects,
279}
280
281impl SidebarSection {
282 pub fn label(self) -> &'static str {
283 match self {
284 Self::Views => "Views",
285 Self::Workspaces => "Workspaces",
286 Self::Projects => "Projects",
287 }
288 }
289
290 fn meta_key(self) -> &'static str {
291 match self {
292 Self::Views => "tui_sidebar_views_collapsed",
293 Self::Workspaces => "tui_sidebar_scope_collapsed",
294 Self::Projects => "tui_sidebar_projects_collapsed",
295 }
296 }
297}
298
299impl Database {
300 pub async fn collapsed_sidebar_sections(&self) -> Result<BTreeSet<SidebarSection>> {
301 let mut conn = self.acquire_reader().await?;
302 let mut sections = BTreeSet::new();
303 for section in [
304 SidebarSection::Views,
305 SidebarSection::Workspaces,
306 SidebarSection::Projects,
307 ] {
308 if get_meta(&mut conn, section.meta_key())
309 .await?
310 .as_deref()
311 .map(str::trim)
312 == Some("true")
313 {
314 sections.insert(section);
315 }
316 }
317 Ok(sections)
318 }
319
320 pub async fn set_sidebar_section_collapsed(
321 &self,
322 section: SidebarSection,
323 collapsed: bool,
324 ) -> Result<()> {
325 let mut conn = self.acquire_writer().await?;
326 let mut tx = begin_immediate(&mut conn).await?;
327 set_meta(
328 &mut tx,
329 section.meta_key(),
330 if collapsed { "true" } else { "false" },
331 )
332 .await?;
333 tx.commit().await?;
334 Ok(())
335 }
336}
337
338pub type TriageReviews = BTreeMap<String, i64>;
341
342impl Database {
343 pub async fn triage_reviews(&self) -> Result<TriageReviews> {
344 let mut conn = self.acquire_reader().await?;
345 Ok(parse_triage_reviews(
346 get_meta(&mut conn, TRIAGE_REVIEWS_META_KEY)
347 .await?
348 .as_deref(),
349 ))
350 }
351
352 pub async fn set_triage_review(
356 &self,
357 task_id: &str,
358 reviewed_at: Option<i64>,
359 now_seconds: i64,
360 ) -> Result<Option<i64>> {
361 let mut conn = self.acquire_writer().await?;
362 let mut tx = begin_immediate(&mut conn).await?;
363 let mut reviews =
364 parse_triage_reviews(get_meta(&mut tx, TRIAGE_REVIEWS_META_KEY).await?.as_deref());
365 let previous = match reviewed_at {
366 Some(at) => reviews.insert(task_id.to_string(), at),
367 None => reviews.remove(task_id),
368 };
369 let horizon = now_seconds - crate::queue::STALE_TODO_DAYS * 86_400;
370 reviews.retain(|_, at| *at > horizon);
371 set_meta(
372 &mut tx,
373 TRIAGE_REVIEWS_META_KEY,
374 &serde_json::to_string(&reviews)?,
375 )
376 .await?;
377 tx.commit().await?;
378 Ok(previous)
379 }
380}
381
382fn parse_triage_reviews(value: Option<&str>) -> TriageReviews {
383 value
384 .and_then(|value| serde_json::from_str(value).ok())
385 .unwrap_or_default()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[tokio::test]
393 async fn daemon_health_preserves_first_seen_and_clears_on_success_and_reset() {
394 let dir = tempfile::tempdir().unwrap();
395 let path = dir.path().join("health.sqlite");
396 let database = Database::open(&path).await.unwrap();
397 assert_eq!(
398 database.daemon_sync_state().await.unwrap(),
399 DaemonSyncState::default()
400 );
401 database.record_daemon_sync_round(100, None).await.unwrap();
402 database
403 .record_daemon_sync_round(110, Some(("network", "retry", 111)))
404 .await
405 .unwrap();
406 database
407 .record_daemon_sync_round(120, Some(("network", "retry", 122)))
408 .await
409 .unwrap();
410 let state = database.daemon_sync_state().await.unwrap();
411 assert_eq!(state.last_success_at, Some(100));
412 assert_eq!(state.error.as_ref().unwrap().since, 110);
413 assert_eq!(state.next_retry_at, Some(122));
414 sqlx::raw_sql(
415 "CREATE TRIGGER reject_health_write BEFORE UPDATE ON meta
416 WHEN NEW.key = 'sync_daemon_health'
417 BEGIN SELECT RAISE(ABORT, 'redundant health write'); END;",
418 )
419 .execute(&mut *database.acquire_writer().await.unwrap())
420 .await
421 .unwrap();
422 database
423 .record_daemon_sync_round(121, Some(("network", "retry", 122)))
424 .await
425 .unwrap();
426 sqlx::raw_sql("DROP TRIGGER reject_health_write")
427 .execute(&mut *database.acquire_writer().await.unwrap())
428 .await
429 .unwrap();
430 assert_eq!(
431 Database::open(&path)
432 .await
433 .unwrap()
434 .daemon_sync_state()
435 .await
436 .unwrap(),
437 state
438 );
439 database
440 .record_daemon_sync_round(130, Some(("keys", "unlock", 140)))
441 .await
442 .unwrap();
443 assert_eq!(
444 database
445 .daemon_sync_state()
446 .await
447 .unwrap()
448 .error
449 .unwrap()
450 .since,
451 130
452 );
453 database.record_daemon_sync_round(150, None).await.unwrap();
454 assert_eq!(
455 database.daemon_sync_state().await.unwrap(),
456 DaemonSyncState {
457 last_success_at: Some(150),
458 error: None,
459 next_retry_at: None,
460 }
461 );
462 database.clear_sync_state().await.unwrap();
463 assert_eq!(
464 database.daemon_sync_state().await.unwrap(),
465 DaemonSyncState::default()
466 );
467 }
468
469 #[tokio::test]
470 async fn deleted_queue_workspace_falls_back_to_the_renamed_default_workspace() {
471 let dir = tempfile::tempdir().unwrap();
472 let path = dir.path().join("queue.sqlite");
473 let database = Database::open(&path).await.unwrap();
474 database.create_workspace("Alpha").await.unwrap();
475 database.rename_workspace("default", "Home").await.unwrap();
476 let personal = database.create_workspace("Personal").await.unwrap();
477 database.select_queue_workspace(&personal.id).await.unwrap();
478 crate::test_support::set_established_protocol(&database, 19)
479 .await
480 .unwrap();
481 database
482 .delete_workspace(&personal.id, &[], true)
483 .await
484 .unwrap();
485 assert_eq!(
486 database.restore_queue_workspace().await.unwrap().key,
487 "home"
488 );
489 database.close().await;
490 let reopened = Database::open(&path).await.unwrap();
491 assert_eq!(
492 reopened.restore_queue_workspace().await.unwrap().key,
493 "home"
494 );
495 }
496
497 #[tokio::test]
498 async fn sync_access_refusal_persists_until_cleared() {
499 let database = Database::open(std::path::Path::new(":memory:"))
500 .await
501 .unwrap();
502 assert_eq!(database.sync_access_refusal().await.unwrap(), None);
503
504 let recorded = database.record_sync_access_refusal().await.unwrap();
505 assert!(!recorded.at.is_empty());
506 assert_eq!(
507 database.sync_access_refusal().await.unwrap(),
508 Some(recorded)
509 );
510
511 database.clear_sync_access_refusal().await.unwrap();
512 assert_eq!(database.sync_access_refusal().await.unwrap(), None);
513 }
514
515 #[tokio::test]
516 async fn reminder_outcome_replaces_existing_key() {
517 let database = Database::open(std::path::Path::new(":memory:"))
518 .await
519 .unwrap();
520 let entry = |outcome: &str| ReminderLogEntry {
521 kind: "available".into(),
522 task_id: "T1".into(),
523 trigger: "2026-10-14T09:00:00Z".into(),
524 outcome: outcome.into(),
525 };
526
527 database
528 .record_reminders(&[entry("waiting")])
529 .await
530 .unwrap();
531 database.record_reminders(&[entry("sent")]).await.unwrap();
532
533 assert_eq!(database.reminder_log().await.unwrap(), vec![entry("sent")]);
534 }
535
536 #[tokio::test]
537 async fn sync_vault_deletion_persists_apart_from_access_refusal_until_cleared() {
538 let database = Database::open(std::path::Path::new(":memory:"))
539 .await
540 .unwrap();
541 assert_eq!(database.sync_vault_deletion().await.unwrap(), None);
542
543 let recorded = database.record_sync_vault_deletion().await.unwrap();
544 assert_eq!(
545 database.sync_vault_deletion().await.unwrap(),
546 Some(recorded)
547 );
548 assert_eq!(database.sync_access_refusal().await.unwrap(), None);
549
550 database.clear_sync_vault_deletion().await.unwrap();
551 assert_eq!(database.sync_vault_deletion().await.unwrap(), None);
552 }
553
554 #[tokio::test]
555 async fn sidebar_preferences_are_local_and_invalid_values_default_to_expanded() {
556 let database = Database::open(std::path::Path::new(":memory:"))
557 .await
558 .unwrap();
559 let before: i64 = {
560 let mut conn = database.acquire_reader().await.unwrap();
561 sqlx::query_scalar("SELECT COUNT(*) FROM changes")
562 .fetch_one(&mut *conn)
563 .await
564 .unwrap()
565 };
566 {
567 let mut conn = database.acquire_writer().await.unwrap();
568 set_meta(&mut conn, "tui_sidebar_scope_collapsed", "true")
569 .await
570 .unwrap();
571 }
572 {
573 let mut conn = database.acquire_writer().await.unwrap();
574 set_meta(&mut conn, SidebarSection::Views.meta_key(), "invalid")
575 .await
576 .unwrap();
577 }
578 assert_eq!(
579 database.collapsed_sidebar_sections().await.unwrap(),
580 BTreeSet::from([SidebarSection::Workspaces])
581 );
582 let mut conn = database.acquire_reader().await.unwrap();
583 let after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM changes")
584 .fetch_one(&mut *conn)
585 .await
586 .unwrap();
587 assert_eq!(before, after);
588 }
589
590 #[tokio::test]
591 async fn triage_reviews_round_trip_and_drop_expired_marks() {
592 let database = Database::open(std::path::Path::new(":memory:"))
593 .await
594 .unwrap();
595 let now = 100 * 86_400;
596 database
597 .set_triage_review("OLD", Some(now - 31 * 86_400), now)
598 .await
599 .unwrap();
600 assert_eq!(
601 database
602 .set_triage_review("NEW", Some(now), now)
603 .await
604 .unwrap(),
605 None
606 );
607
608 let reviews = database.triage_reviews().await.unwrap();
609 assert_eq!(reviews.get("NEW"), Some(&now));
610 assert!(!reviews.contains_key("OLD"));
611
612 assert_eq!(
613 database.set_triage_review("NEW", None, now).await.unwrap(),
614 Some(now)
615 );
616 assert!(database.triage_reviews().await.unwrap().is_empty());
617 }
618}