a1ff410d1b
Remove the per-user oneliner watermark, which was previously used to generate ETags for the `/api/oneliners` endpoint. The watermark was intended to track the last successful oneliner write for each user. The ETag generation has been refactored to use a deterministic FNV-1a hash. This hash is computed over a sorted list of tuples containing `(case_id, last_recording_at_ns, oneliner_mtime_ns)` for all visible cases. This approach ensures that the ETag changes whenever any listen-relevant file system modification occurs, such as a new recording, oneliner regeneration, soft-delete, undo, or upload-auto-reopen. This new method is more robust and avoids the complexity of managing per-user state. The `OnelinerWatermark` type alias and its associated logic have been removed from `AppState` and the relevant modules (`transcribe::worker`, `transcribe::recovery`, `routes::oneliners`, `main`). New integration tests have been added to verify that various delete operations (single delete, undo delete, bulk delete) correctly trigger an ETag update, ensuring cache invalidation.
931 lines
37 KiB
Rust
931 lines
37 KiB
Rust
//! Per-case marker store for native clients.
|
|
//!
|
|
//! Each known case (either created locally by this client or seen in a
|
|
//! server snapshot) has one JSON file: `{dir}/{case_id}.json`. The store
|
|
//! keeps an in-memory copy and broadcasts a sorted snapshot on every
|
|
//! write via [`tokio::sync::watch`].
|
|
//!
|
|
//! Two mutation paths treat the server snapshot asymmetrically:
|
|
//!
|
|
//! - [`CaseStore::merge_server_snapshot`] **adds or updates** markers —
|
|
//! never deletes. `/api/oneliners` is a sliding-window view, so
|
|
//! "missing from snapshot" on its own is ambiguous (could be deleted
|
|
//! server-side, could simply have fallen out of the window).
|
|
//! - [`CaseStore::reconcile_with_server_snapshot`] **removes** synced
|
|
//! markers the server doesn't know about **anymore**, scoped to the
|
|
//! window reported by `OnelinersResponse.as_of - window_hours`. Pending
|
|
//! markers (`synced_to_server = false`) and markers older than the
|
|
//! window are preserved — their absence from the snapshot carries no
|
|
//! information about deletion.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use doctate_common::oneliners::OnelinersResponse;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use time::{Duration, OffsetDateTime};
|
|
use tokio::sync::{watch, Mutex};
|
|
use tracing::{debug, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
/// On-disk representation of a single case known to this client.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct CaseMarker {
|
|
pub case_id: Uuid,
|
|
/// RFC3339 UTC; set once at creation or on first server-snapshot merge.
|
|
pub created_at: String,
|
|
/// RFC3339 UTC; bumped on every new recording + on server-snapshot
|
|
/// updates. Drives the UI sort order (newest activity first).
|
|
pub last_activity_at: String,
|
|
/// `true` once this case has been seen in any `/api/oneliners`
|
|
/// response or after a successful upload ACK. Drives two clean-up
|
|
/// paths: [`CaseStore::reconcile_with_server_snapshot`] only removes
|
|
/// synced markers the server no longer lists, and
|
|
/// [`CaseStore::cleanup_stale`] only ages out synced markers. Pending
|
|
/// (`= false`) markers guard unsent uploads and are never touched by
|
|
/// either — losing one would silently erase a patient recording.
|
|
pub synced_to_server: bool,
|
|
/// `None` while transcription / oneliner generation is pending; set
|
|
/// from the server snapshot when available.
|
|
pub oneliner: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum CaseStoreError {
|
|
#[error("I/O error at {path}: {source}")]
|
|
Io {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
#[error("serialize marker: {0}")]
|
|
Serialize(#[from] serde_json::Error),
|
|
#[error("format timestamp: {0}")]
|
|
Format(#[from] time::error::Format),
|
|
#[error("parse timestamp: {0}")]
|
|
Parse(#[from] time::error::Parse),
|
|
}
|
|
|
|
type Snapshot = Arc<Vec<CaseMarker>>;
|
|
|
|
/// Shared case store. Cheap to `clone()`; clones share the same state.
|
|
#[derive(Clone)]
|
|
pub struct CaseStore {
|
|
dir: PathBuf,
|
|
state: Arc<Mutex<HashMap<Uuid, CaseMarker>>>,
|
|
tx: watch::Sender<Snapshot>,
|
|
}
|
|
|
|
impl CaseStore {
|
|
/// Open or create the store at `dir`. Reads every `*.json` file in
|
|
/// the directory as an initial marker. Returns the store plus a
|
|
/// receiver that yields sorted snapshots (newest `last_activity_at`
|
|
/// first) on every mutation.
|
|
pub async fn open(dir: PathBuf) -> Result<(Self, watch::Receiver<Snapshot>), CaseStoreError> {
|
|
tokio::fs::create_dir_all(&dir)
|
|
.await
|
|
.map_err(|e| CaseStoreError::Io {
|
|
path: dir.clone(),
|
|
source: e,
|
|
})?;
|
|
|
|
let initial = read_all_markers(&dir).await?;
|
|
let map: HashMap<Uuid, CaseMarker> =
|
|
initial.into_iter().map(|m| (m.case_id, m)).collect();
|
|
let snapshot = sorted_snapshot(&map);
|
|
let (tx, rx) = watch::channel(snapshot);
|
|
|
|
let store = Self {
|
|
dir,
|
|
state: Arc::new(Mutex::new(map)),
|
|
tx,
|
|
};
|
|
Ok((store, rx))
|
|
}
|
|
|
|
/// Append a new case the client itself just started (no server
|
|
/// knowledge yet). Writes the marker atomically and broadcasts the
|
|
/// new snapshot. If a marker with the same `case_id` already exists,
|
|
/// it is overwritten — callers must check before if that matters.
|
|
pub async fn create_local(
|
|
&self,
|
|
case_id: Uuid,
|
|
at: OffsetDateTime,
|
|
) -> Result<CaseMarker, CaseStoreError> {
|
|
let ts = format_utc(at)?;
|
|
let marker = CaseMarker {
|
|
case_id,
|
|
created_at: ts.clone(),
|
|
last_activity_at: ts,
|
|
synced_to_server: false,
|
|
oneliner: None,
|
|
};
|
|
let mut state = self.state.lock().await;
|
|
write_marker_file(&self.dir, &marker).await?;
|
|
state.insert(case_id, marker.clone());
|
|
self.broadcast(&state);
|
|
Ok(marker)
|
|
}
|
|
|
|
/// Record new activity on an existing case (e.g. a follow-up
|
|
/// recording). Creates the marker if missing — callers may invoke
|
|
/// this after an upload without caring whether `create_local` ran
|
|
/// first. `synced_to_server` is preserved.
|
|
pub async fn mark_activity(
|
|
&self,
|
|
case_id: Uuid,
|
|
at: OffsetDateTime,
|
|
) -> Result<(), CaseStoreError> {
|
|
let ts = format_utc(at)?;
|
|
let mut state = self.state.lock().await;
|
|
let marker = state.entry(case_id).or_insert_with(|| CaseMarker {
|
|
case_id,
|
|
created_at: ts.clone(),
|
|
last_activity_at: ts.clone(),
|
|
synced_to_server: false,
|
|
oneliner: None,
|
|
});
|
|
marker.last_activity_at = ts;
|
|
write_marker_file(&self.dir, marker).await?;
|
|
self.broadcast(&state);
|
|
Ok(())
|
|
}
|
|
|
|
/// Mark an existing case as confirmed-by-server. Called by the
|
|
/// `ServerSync` worker immediately after a successful upload ACK — so
|
|
/// the `synced_to_server` flag reflects reality without waiting for
|
|
/// the next `/api/oneliners` poll to round-trip.
|
|
///
|
|
/// No-op if the marker does not exist locally (e.g. `clear_all` was
|
|
/// called between `submit` and `ACK`). `last_activity_at` is **not**
|
|
/// touched: its semantics are "last user activity" (recording end),
|
|
/// not "last server confirmation".
|
|
pub async fn mark_synced(&self, case_id: Uuid) -> Result<(), CaseStoreError> {
|
|
let mut state = self.state.lock().await;
|
|
let Some(marker) = state.get_mut(&case_id) else {
|
|
return Ok(());
|
|
};
|
|
if marker.synced_to_server {
|
|
return Ok(()); // already true, skip the disk write
|
|
}
|
|
marker.synced_to_server = true;
|
|
let marker_clone = marker.clone();
|
|
write_marker_file(&self.dir, &marker_clone).await?;
|
|
self.broadcast(&state);
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply a server `/api/oneliners` response. For every entry in the
|
|
/// snapshot: insert (with `synced_to_server = true`) or update the
|
|
/// local marker (oneliner + last_activity_at from server timestamps,
|
|
/// taking the newer of local vs. server). Never deletes.
|
|
///
|
|
/// The `last_activity_at` seed from the server prefers
|
|
/// `last_recording_at` (most recent m4a mtime — the authoritative
|
|
/// "when was this case last worked on?") over `updated_at` (oneliner
|
|
/// regeneration time, which collapses to the recovery-scan moment
|
|
/// after a restart). Falls back to `created_at` if neither is set.
|
|
pub async fn merge_server_snapshot(
|
|
&self,
|
|
snapshot: &OnelinersResponse,
|
|
) -> Result<(), CaseStoreError> {
|
|
let mut state = self.state.lock().await;
|
|
for entry in &snapshot.oneliners {
|
|
let Ok(case_id) = Uuid::parse_str(&entry.case_id) else {
|
|
warn!(case_id = %entry.case_id, "server snapshot entry with invalid case_id, skipped");
|
|
continue;
|
|
};
|
|
let server_activity = entry
|
|
.last_recording_at
|
|
.clone()
|
|
.or_else(|| entry.updated_at.clone())
|
|
.unwrap_or_else(|| entry.created_at.clone());
|
|
|
|
let updated_marker = match state.get(&case_id) {
|
|
Some(existing) => {
|
|
let mut m = existing.clone();
|
|
m.synced_to_server = true;
|
|
m.oneliner = entry.oneliner.clone();
|
|
// Keep whichever `last_activity_at` is newer — the
|
|
// client may have recorded an addendum the server
|
|
// hasn't processed yet.
|
|
if server_activity > m.last_activity_at {
|
|
m.last_activity_at = server_activity;
|
|
}
|
|
m
|
|
}
|
|
None => CaseMarker {
|
|
case_id,
|
|
created_at: entry.created_at.clone(),
|
|
last_activity_at: server_activity,
|
|
synced_to_server: true,
|
|
oneliner: entry.oneliner.clone(),
|
|
},
|
|
};
|
|
write_marker_file(&self.dir, &updated_marker).await?;
|
|
state.insert(case_id, updated_marker);
|
|
}
|
|
self.broadcast(&state);
|
|
Ok(())
|
|
}
|
|
|
|
/// Reconcile local markers against an authoritative server snapshot.
|
|
///
|
|
/// Removes exactly those markers that are all of:
|
|
/// 1. `synced_to_server == true` (server has seen this case before),
|
|
/// 2. `last_activity_at >= as_of - window_hours` (inside the window
|
|
/// the response is authoritative for), and
|
|
/// 3. `case_id ∉ response.oneliners` (server no longer reports it).
|
|
///
|
|
/// In that combination the server's silence is unambiguous: the
|
|
/// case was deleted (web UI soft-delete, or future hard-delete by a
|
|
/// retention sweep). Pending markers (`synced_to_server = false`)
|
|
/// guard unsent uploads and are left alone. Markers older than the
|
|
/// window are also preserved — their absence from the snapshot is
|
|
/// expected and says nothing about deletion.
|
|
///
|
|
/// Uses the server-reported `as_of` (not the client clock) as the
|
|
/// window anchor, so clock skew between client and server cannot
|
|
/// cause spurious deletions.
|
|
pub async fn reconcile_with_server_snapshot(
|
|
&self,
|
|
response: &OnelinersResponse,
|
|
) -> Result<usize, CaseStoreError> {
|
|
let as_of = OffsetDateTime::parse(&response.as_of, &Rfc3339)?;
|
|
let window_start = as_of - Duration::hours(response.window_hours as i64);
|
|
let window_start_str = format_utc(window_start)?;
|
|
|
|
let server_ids: HashSet<Uuid> = response
|
|
.oneliners
|
|
.iter()
|
|
.filter_map(|e| Uuid::parse_str(&e.case_id).ok())
|
|
.collect();
|
|
|
|
let mut state = self.state.lock().await;
|
|
let to_remove: Vec<Uuid> = state
|
|
.iter()
|
|
.filter(|(id, m)| {
|
|
m.synced_to_server
|
|
&& m.last_activity_at >= window_start_str
|
|
&& !server_ids.contains(id)
|
|
})
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
let count = to_remove.len();
|
|
for id in &to_remove {
|
|
let path = self.dir.join(format!("{id}.json"));
|
|
match tokio::fs::remove_file(&path).await {
|
|
Ok(_) => info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)"),
|
|
Err(e) => warn!(case_id = %id, error = %e, "failed to remove reconciled marker"),
|
|
}
|
|
state.remove(id);
|
|
}
|
|
if count > 0 {
|
|
self.broadcast(&state);
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
/// Drop markers whose last activity is older than `cutoff` **and**
|
|
/// that the server has already confirmed (`synced_to_server = true`).
|
|
/// Pending/unsynced markers are preserved — they guard uploads that
|
|
/// have not yet reached the server, and their loss would silently
|
|
/// erase patient recordings from the client's awareness.
|
|
///
|
|
/// Returns the number of markers removed. Intended as an
|
|
/// app-startup pass so the data-minimization rule holds: metadata
|
|
/// ages out after a few days even without an explicit UI action.
|
|
pub async fn cleanup_stale(
|
|
&self,
|
|
cutoff: OffsetDateTime,
|
|
) -> Result<usize, CaseStoreError> {
|
|
let cutoff_str = format_utc(cutoff)?;
|
|
let mut state = self.state.lock().await;
|
|
let to_remove: Vec<Uuid> = state
|
|
.iter()
|
|
.filter(|(_, m)| m.synced_to_server && m.last_activity_at < cutoff_str)
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
let count = to_remove.len();
|
|
for id in &to_remove {
|
|
let path = self.dir.join(format!("{id}.json"));
|
|
match tokio::fs::remove_file(&path).await {
|
|
Ok(_) => tracing::info!(case_id = %id, path = %path.display(), "stale marker removed"),
|
|
Err(e) => warn!(case_id = %id, error = %e, "failed to remove stale marker"),
|
|
}
|
|
state.remove(id);
|
|
}
|
|
if count > 0 {
|
|
self.broadcast(&state);
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
/// Remove every marker from memory and disk. Used on config change
|
|
/// (new API key = potentially different user).
|
|
pub async fn clear_all(&self) -> Result<(), CaseStoreError> {
|
|
let mut state = self.state.lock().await;
|
|
let mut entries =
|
|
tokio::fs::read_dir(&self.dir)
|
|
.await
|
|
.map_err(|e| CaseStoreError::Io {
|
|
path: self.dir.clone(),
|
|
source: e,
|
|
})?;
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
if let Err(e) = tokio::fs::remove_file(&path).await {
|
|
warn!(path = %path.display(), error = %e, "failed to remove marker during clear");
|
|
}
|
|
}
|
|
state.clear();
|
|
self.broadcast(&state);
|
|
Ok(())
|
|
}
|
|
|
|
/// Current in-memory list, for tests and inspection.
|
|
pub async fn list(&self) -> Vec<CaseMarker> {
|
|
let state = self.state.lock().await;
|
|
sorted_vec(&state)
|
|
}
|
|
|
|
fn broadcast(&self, state: &HashMap<Uuid, CaseMarker>) {
|
|
let snapshot = sorted_snapshot(state);
|
|
// receiver-count=0 is fine (nobody listening); ignore.
|
|
let _ = self.tx.send(snapshot);
|
|
}
|
|
}
|
|
|
|
fn format_utc(at: OffsetDateTime) -> Result<String, time::error::Format> {
|
|
at.to_offset(time::UtcOffset::UTC).format(&Rfc3339)
|
|
}
|
|
|
|
fn sorted_snapshot(state: &HashMap<Uuid, CaseMarker>) -> Snapshot {
|
|
Arc::new(sorted_vec(state))
|
|
}
|
|
|
|
fn sorted_vec(state: &HashMap<Uuid, CaseMarker>) -> Vec<CaseMarker> {
|
|
let mut v: Vec<CaseMarker> = state.values().cloned().collect();
|
|
v.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
|
v
|
|
}
|
|
|
|
async fn read_all_markers(dir: &Path) -> Result<Vec<CaseMarker>, CaseStoreError> {
|
|
let mut out = Vec::new();
|
|
let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| CaseStoreError::Io {
|
|
path: dir.to_owned(),
|
|
source: e,
|
|
})?;
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
match read_marker_file(&path).await {
|
|
Ok(m) => out.push(m),
|
|
Err(e) => {
|
|
// A single unreadable file should not poison the store;
|
|
// log and skip so recovery of the rest proceeds.
|
|
warn!(path = %path.display(), error = %e, "skipping unreadable marker");
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
async fn read_marker_file(path: &Path) -> Result<CaseMarker, CaseStoreError> {
|
|
let bytes = tokio::fs::read(path).await.map_err(|e| CaseStoreError::Io {
|
|
path: path.to_owned(),
|
|
source: e,
|
|
})?;
|
|
Ok(serde_json::from_slice(&bytes)?)
|
|
}
|
|
|
|
/// Write a marker atomically: temp-file + rename. The rename on the same
|
|
/// filesystem is atomic on POSIX and NTFS, so readers never see a
|
|
/// partially-written file.
|
|
async fn write_marker_file(dir: &Path, marker: &CaseMarker) -> Result<(), CaseStoreError> {
|
|
let bytes = serde_json::to_vec_pretty(marker)?;
|
|
let final_path = dir.join(format!("{}.json", marker.case_id));
|
|
let tmp_path = dir.join(format!("{}.json.tmp", marker.case_id));
|
|
tokio::fs::write(&tmp_path, &bytes)
|
|
.await
|
|
.map_err(|e| CaseStoreError::Io {
|
|
path: tmp_path.clone(),
|
|
source: e,
|
|
})?;
|
|
tokio::fs::rename(&tmp_path, &final_path)
|
|
.await
|
|
.map_err(|e| CaseStoreError::Io {
|
|
path: final_path.clone(),
|
|
source: e,
|
|
})?;
|
|
debug!(path = %final_path.display(), "marker written");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use doctate_common::oneliners::OnelinerEntry;
|
|
use tempfile::TempDir;
|
|
|
|
fn t(s: &str) -> OffsetDateTime {
|
|
OffsetDateTime::parse(s, &Rfc3339).unwrap()
|
|
}
|
|
|
|
fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry {
|
|
OnelinerEntry {
|
|
case_id: case_id.to_string(),
|
|
oneliner: oneliner.map(str::to_owned),
|
|
created_at: created.to_owned(),
|
|
last_recording_at: Some(created.to_owned()),
|
|
updated_at: Some(created.to_owned()),
|
|
}
|
|
}
|
|
|
|
fn server_snapshot(entries: Vec<OnelinerEntry>) -> OnelinersResponse {
|
|
OnelinersResponse {
|
|
as_of: "2026-04-18T10:00:00Z".into(),
|
|
window_hours: 72,
|
|
oneliners: entries,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_local_persists_and_reloads() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, mut rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
assert!(rx.borrow().is_empty());
|
|
|
|
let id = Uuid::new_v4();
|
|
let marker = store.create_local(id, t("2026-04-18T10:32:00Z")).await.unwrap();
|
|
assert_eq!(marker.case_id, id);
|
|
assert!(!marker.synced_to_server);
|
|
assert!(marker.oneliner.is_none());
|
|
|
|
rx.changed().await.unwrap();
|
|
let snap = rx.borrow().clone();
|
|
assert_eq!(snap.len(), 1);
|
|
assert_eq!(snap[0].case_id, id);
|
|
|
|
// Reopen: state must survive.
|
|
drop(store);
|
|
let (store2, _rx2) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
assert_eq!(store2.list().await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_activity_updates_timestamp() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
store.mark_activity(id, t("2026-04-18T10:45:00Z")).await.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(list[0].last_activity_at, "2026-04-18T10:45:00Z");
|
|
// `created_at` must not change.
|
|
assert_eq!(list[0].created_at, "2026-04-18T10:00:00Z");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_activity_creates_if_missing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.mark_activity(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
assert_eq!(store.list().await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_adds_unknown_server_case() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
let snap = server_snapshot(vec![server_entry(
|
|
id,
|
|
Some("Kniegelenk re."),
|
|
"2026-04-18T09:30:00Z",
|
|
)]);
|
|
store.merge_server_snapshot(&snap).await.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(list[0].case_id, id);
|
|
assert_eq!(list[0].oneliner.as_deref(), Some("Kniegelenk re."));
|
|
assert!(list[0].synced_to_server);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_updates_existing_local_marker() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
|
|
let snap = server_snapshot(vec![server_entry(
|
|
id,
|
|
Some("Hypertonie"),
|
|
"2026-04-18T10:00:00Z",
|
|
)]);
|
|
store.merge_server_snapshot(&snap).await.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list.len(), 1);
|
|
assert!(list[0].synced_to_server);
|
|
assert_eq!(list[0].oneliner.as_deref(), Some("Hypertonie"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_keeps_local_when_missing_from_server() {
|
|
// User recorded locally, upload not yet through. Server snapshot
|
|
// is empty — marker must survive.
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
|
|
let snap = server_snapshot(vec![]);
|
|
store.merge_server_snapshot(&snap).await.unwrap();
|
|
|
|
assert_eq!(store.list().await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_keeps_synced_marker_when_missing_from_server() {
|
|
// Case was synced earlier, now out of the server's time window.
|
|
// Must not be deleted.
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
let synced_snap = server_snapshot(vec![server_entry(
|
|
id,
|
|
Some("old"),
|
|
"2026-04-18T10:00:00Z",
|
|
)]);
|
|
store.merge_server_snapshot(&synced_snap).await.unwrap();
|
|
|
|
// Next poll: server window moved, case fell out.
|
|
store.merge_server_snapshot(&server_snapshot(vec![])).await.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list.len(), 1);
|
|
assert!(list[0].synced_to_server);
|
|
assert_eq!(list[0].oneliner.as_deref(), Some("old"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_prefers_newer_last_activity() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
|
store.mark_activity(id, t("2026-04-18T11:00:00Z")).await.unwrap();
|
|
|
|
// Server says last activity was 10:00 (stale) — local 11:00 must win.
|
|
let snap = server_snapshot(vec![server_entry(
|
|
id,
|
|
Some("x"),
|
|
"2026-04-18T10:00:00Z",
|
|
)]);
|
|
store.merge_server_snapshot(&snap).await.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list[0].last_activity_at, "2026-04-18T11:00:00Z");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_all_removes_everything() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
store.create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z")).await.unwrap();
|
|
assert_eq!(store.list().await.len(), 2);
|
|
|
|
store.clear_all().await.unwrap();
|
|
assert!(store.list().await.is_empty());
|
|
// Marker files on disk are gone.
|
|
let mut count = 0;
|
|
let mut entries = tokio::fs::read_dir(tmp.path()).await.unwrap();
|
|
while let Ok(Some(e)) = entries.next_entry().await {
|
|
if e.path().extension().and_then(|s| s.to_str()) == Some("json") {
|
|
count += 1;
|
|
}
|
|
}
|
|
assert_eq!(count, 0);
|
|
}
|
|
|
|
/// The client must seed `last_activity_at` from `last_recording_at`
|
|
/// (the authoritative dictation-activity timestamp), not from
|
|
/// `updated_at` (oneliner regeneration time, which collapses to a
|
|
/// narrow window after a startup recovery scan and breaks ordering).
|
|
#[tokio::test]
|
|
async fn merge_prefers_last_recording_over_updated_at() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
|
|
// Server: case created yesterday, last dictation 30 min ago,
|
|
// oneliner regenerated just now in a recovery scan.
|
|
let entry = OnelinerEntry {
|
|
case_id: id.to_string(),
|
|
oneliner: Some("x".into()),
|
|
created_at: "2026-04-17T09:00:00Z".into(),
|
|
last_recording_at: Some("2026-04-18T09:30:00Z".into()),
|
|
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
|
};
|
|
store
|
|
.merge_server_snapshot(&server_snapshot(vec![entry]))
|
|
.await
|
|
.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list[0].last_activity_at, "2026-04-18T09:30:00Z");
|
|
}
|
|
|
|
/// Backward-compat: if the server response omits `last_recording_at`
|
|
/// (older server or future schema shift), the client falls back to
|
|
/// `updated_at` then `created_at`.
|
|
#[tokio::test]
|
|
async fn merge_falls_back_to_updated_at_when_last_recording_missing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
let entry = OnelinerEntry {
|
|
case_id: id.to_string(),
|
|
oneliner: Some("x".into()),
|
|
created_at: "2026-04-17T09:00:00Z".into(),
|
|
last_recording_at: None,
|
|
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
|
};
|
|
store
|
|
.merge_server_snapshot(&server_snapshot(vec![entry]))
|
|
.await
|
|
.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cleanup_stale_removes_old_synced_markers() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let old_synced = Uuid::new_v4();
|
|
let young_synced = Uuid::new_v4();
|
|
let old_unsynced = Uuid::new_v4();
|
|
|
|
// old_synced: created 4 days ago, then merged in → synced=true, old
|
|
store.create_local(old_synced, t("2026-04-14T10:00:00Z")).await.unwrap();
|
|
store
|
|
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
|
case_id: old_synced.to_string(),
|
|
oneliner: Some("old".into()),
|
|
created_at: "2026-04-14T10:00:00Z".into(),
|
|
last_recording_at: Some("2026-04-14T10:00:00Z".into()),
|
|
updated_at: Some("2026-04-14T10:00:00Z".into()),
|
|
}]))
|
|
.await
|
|
.unwrap();
|
|
|
|
// young_synced: created today, synced
|
|
store.create_local(young_synced, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
store
|
|
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
|
case_id: young_synced.to_string(),
|
|
oneliner: Some("young".into()),
|
|
created_at: "2026-04-18T10:00:00Z".into(),
|
|
last_recording_at: Some("2026-04-18T10:00:00Z".into()),
|
|
updated_at: Some("2026-04-18T10:00:00Z".into()),
|
|
}]))
|
|
.await
|
|
.unwrap();
|
|
|
|
// old_unsynced: created 4 days ago, NEVER merged in → pending upload
|
|
store.create_local(old_unsynced, t("2026-04-14T10:00:00Z")).await.unwrap();
|
|
|
|
// Cutoff: 72h before the "today" markers, i.e. 2026-04-15.
|
|
let cutoff = t("2026-04-15T10:00:00Z");
|
|
let removed = store.cleanup_stale(cutoff).await.unwrap();
|
|
|
|
assert_eq!(removed, 1, "only old+synced must be removed");
|
|
let list = store.list().await;
|
|
let ids: Vec<Uuid> = list.iter().map(|m| m.case_id).collect();
|
|
assert!(ids.contains(&young_synced), "young synced must stay");
|
|
assert!(
|
|
ids.contains(&old_unsynced),
|
|
"old unsynced must stay (pending upload)"
|
|
);
|
|
assert!(!ids.contains(&old_synced), "old synced must be gone");
|
|
|
|
// Disk: old_synced's file deleted.
|
|
let old_path = tmp.path().join(format!("{old_synced}.json"));
|
|
assert!(!old_path.exists(), "marker file for old_synced must be deleted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cleanup_stale_is_noop_when_nothing_old() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
let removed = store.cleanup_stale(t("2026-01-01T00:00:00Z")).await.unwrap();
|
|
assert_eq!(removed, 0);
|
|
assert_eq!(store.list().await.len(), 1);
|
|
}
|
|
|
|
/// Helper: build a snapshot with an explicit `as_of` anchor.
|
|
fn snapshot_as_of(as_of: &str, entries: Vec<OnelinerEntry>) -> OnelinersResponse {
|
|
OnelinersResponse {
|
|
as_of: as_of.into(),
|
|
window_hours: 72,
|
|
oneliners: entries,
|
|
}
|
|
}
|
|
|
|
/// Core case: a synced marker whose `last_activity_at` sits inside the
|
|
/// server's window, but whose `case_id` is missing from the snapshot →
|
|
/// that can only mean server-side deletion, so the marker goes.
|
|
#[tokio::test]
|
|
async fn reconcile_removes_synced_marker_missing_from_snapshot() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
|
|
// Seed a synced marker with recent activity.
|
|
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
|
store.mark_synced(id).await.unwrap();
|
|
|
|
// Snapshot authoritative for [2026-04-15T10Z, 2026-04-18T10Z],
|
|
// but the case is not listed — server deleted it.
|
|
let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
|
|
let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();
|
|
|
|
assert_eq!(removed, 1);
|
|
assert!(store.list().await.is_empty());
|
|
let path = tmp.path().join(format!("{id}.json"));
|
|
assert!(!path.exists(), "marker file must be deleted");
|
|
}
|
|
|
|
/// Pending markers guard unsent uploads — they must never be touched
|
|
/// by reconciliation, even if the server knows nothing about them.
|
|
#[tokio::test]
|
|
async fn reconcile_keeps_pending_marker_missing_from_snapshot() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
|
|
// Pending: synced_to_server = false (default after create_local).
|
|
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
|
|
|
let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
|
|
let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();
|
|
|
|
assert_eq!(removed, 0);
|
|
assert_eq!(store.list().await.len(), 1);
|
|
}
|
|
|
|
/// A synced marker older than the window: server not listing it is
|
|
/// expected (fell out of the sliding window), not a deletion signal.
|
|
/// Must survive reconciliation.
|
|
#[tokio::test]
|
|
async fn reconcile_keeps_old_synced_marker_outside_window() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
|
|
// last_activity_at = 2026-04-10 (8 days ago); window = 72h anchor 2026-04-18T10Z
|
|
// → window_start = 2026-04-15T10Z. Marker is outside.
|
|
store.create_local(id, t("2026-04-10T10:00:00Z")).await.unwrap();
|
|
store.mark_synced(id).await.unwrap();
|
|
|
|
let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
|
|
let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();
|
|
|
|
assert_eq!(removed, 0);
|
|
assert_eq!(store.list().await.len(), 1);
|
|
}
|
|
|
|
/// When every local marker is present in the snapshot, reconciliation
|
|
/// is a pure no-op. Important negative test — prevents accidental
|
|
/// over-deletion from a buggy predicate.
|
|
#[tokio::test]
|
|
async fn reconcile_is_noop_when_all_markers_present() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id_a = Uuid::new_v4();
|
|
let id_b = Uuid::new_v4();
|
|
store.create_local(id_a, t("2026-04-18T09:00:00Z")).await.unwrap();
|
|
store.create_local(id_b, t("2026-04-18T09:30:00Z")).await.unwrap();
|
|
store.mark_synced(id_a).await.unwrap();
|
|
store.mark_synced(id_b).await.unwrap();
|
|
|
|
let snap = snapshot_as_of(
|
|
"2026-04-18T10:00:00Z",
|
|
vec![
|
|
server_entry(id_a, Some("a"), "2026-04-18T09:00:00Z"),
|
|
server_entry(id_b, Some("b"), "2026-04-18T09:30:00Z"),
|
|
],
|
|
);
|
|
let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();
|
|
|
|
assert_eq!(removed, 0);
|
|
assert_eq!(store.list().await.len(), 2);
|
|
}
|
|
|
|
/// The window anchor is the server's `as_of`, not the local clock.
|
|
/// A marker that would look "old" against `now` but sits inside the
|
|
/// server's authoritative window must be reconciled correctly based
|
|
/// on the server timestamp.
|
|
#[tokio::test]
|
|
async fn reconcile_uses_server_as_of_not_client_clock() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
|
|
// Marker last_activity_at 2026-04-18T09:00Z, synced.
|
|
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
|
store.mark_synced(id).await.unwrap();
|
|
|
|
// Server reports as_of 2026-04-18T10:00Z (1h after marker,
|
|
// clearly inside 72h window) and does NOT list the case.
|
|
let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
|
|
assert_eq!(
|
|
store.reconcile_with_server_snapshot(&snap).await.unwrap(),
|
|
1,
|
|
"inside server window → must be removed"
|
|
);
|
|
|
|
// Seed a second case for the symmetric assertion.
|
|
let id2 = Uuid::new_v4();
|
|
store.create_local(id2, t("2026-04-18T09:00:00Z")).await.unwrap();
|
|
store.mark_synced(id2).await.unwrap();
|
|
|
|
// Now the server reports a much later as_of. The marker's
|
|
// last_activity_at is further than 72h before as_of, so it's
|
|
// *outside* the authoritative window → keep.
|
|
let snap2 = snapshot_as_of("2026-04-30T10:00:00Z", vec![]);
|
|
assert_eq!(
|
|
store.reconcile_with_server_snapshot(&snap2).await.unwrap(),
|
|
0,
|
|
"outside server window → must survive"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_synced_flips_flag_without_touching_activity() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
assert!(!store.list().await[0].synced_to_server);
|
|
|
|
store.mark_synced(id).await.unwrap();
|
|
let list = store.list().await;
|
|
assert!(list[0].synced_to_server);
|
|
assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_synced_is_noop_when_marker_missing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
// No marker at all — must not fail, must not create one.
|
|
store.mark_synced(Uuid::new_v4()).await.unwrap();
|
|
assert!(store.list().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_synced_is_idempotent() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let id = Uuid::new_v4();
|
|
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
store.mark_synced(id).await.unwrap();
|
|
store.mark_synced(id).await.unwrap(); // second call: no disk churn
|
|
assert!(store.list().await[0].synced_to_server);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sorted_newest_first() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
|
let older = Uuid::new_v4();
|
|
let newer = Uuid::new_v4();
|
|
store.create_local(older, t("2026-04-18T08:00:00Z")).await.unwrap();
|
|
store.create_local(newer, t("2026-04-18T10:00:00Z")).await.unwrap();
|
|
|
|
let list = store.list().await;
|
|
assert_eq!(list[0].case_id, newer);
|
|
assert_eq!(list[1].case_id, older);
|
|
}
|
|
}
|