Refactor upload and polling logic

This commit consolidates the upload and polling functionalities into a
single `ServerSync` worker. The `oneliner_poller` and `uploader` modules
have been removed, and their responsibilities are now handled by
`server_sync`.

The `ServerSync` worker manages both uploading pending recordings and
polling the `/api/oneliners` endpoint. Uploads take precedence, and
polling only occurs when the pending upload directory is empty. The
worker now uses a unified HTTP client and a single `tokio::select!` loop
for managing these tasks.

Key changes include:
- Removal of `oneliner_poller.rs` and `uploader.rs`.
- Introduction of `server_sync.rs` and `upload.rs` in
  `doctate-client-core`.
- `ServerSync` now handles upload events (`UploadEvent`) and provides a
  `WorkerSnapshot` for UI feedback.
- Upload logic has been refactored to handle transient and terminal
  errors more robustly, including file deletion for terminal failures.
- Polling logic is integrated into the `ServerSync` loop, utilizing the
  existing `SnapshotCache` and `CaseStore`.
- The `App` component in `client-desktop` has been updated to use
  `ServerSync` instead of the separate poller and uploader.
- Documentation comments have been updated to reflect the new structure.
This commit is contained in:
2026-04-18 13:24:38 +02:00
parent cab71e6a26
commit 305d739f56
10 changed files with 1473 additions and 1169 deletions
+58
View File
@@ -140,6 +140,30 @@ impl CaseStore {
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,
@@ -647,6 +671,40 @@ mod tests {
assert_eq!(store.list().await.len(), 1);
}
#[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();