feat: server-authoritative case window via users.toml
The /api/oneliners time window is now read per-user from users.toml (window_hours, default 72h). Clients no longer carry a window: client.toml oneliner_window_hours, SyncConfig.window_hours, the ?hours=N query param, and the render_case_list cutoff filter are gone. ETag suffix keeps the effective hours so an admin edit to users.toml invalidates client caches on the next request. OnelinersResponse.window_hours stays in the wire format, but now exists solely to anchor client reconciliation.
This commit is contained in:
@@ -236,6 +236,13 @@ impl CaseStore {
|
||||
|
||||
/// Reconcile local markers against an authoritative server snapshot.
|
||||
///
|
||||
/// The window itself is **read from the server response**
|
||||
/// (`OnelinersResponse.window_hours`). Clients no longer decide how
|
||||
/// wide it is — the policy lives in the server's `users.toml` and
|
||||
/// arrives unchanged in every response. This function's only job
|
||||
/// is to interpret the window: within it, the server's silence
|
||||
/// about a case means "deleted"; outside it, silence means nothing.
|
||||
///
|
||||
/// 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
|
||||
|
||||
@@ -15,9 +15,6 @@ use thiserror::Error;
|
||||
/// Fallback polling cadence for `/api/oneliners` in seconds. Chosen to
|
||||
/// feel real-time without hammering the server.
|
||||
pub const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5;
|
||||
/// Fallback window passed as `?hours=N` to `/api/oneliners`. 72 h so
|
||||
/// Monday-morning shows Friday's unfinished work.
|
||||
pub const DEFAULT_ONELINER_WINDOW_HOURS: u32 = 72;
|
||||
|
||||
/// Client configuration loaded from a TOML file at a platform-specific
|
||||
/// path. All fields except the two durations are mandatory.
|
||||
@@ -31,10 +28,6 @@ pub struct Config {
|
||||
/// built-in default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oneliner_poll_interval_seconds: Option<u64>,
|
||||
/// Time window handed to the server as `?hours=N`. Omit in TOML to
|
||||
/// use the built-in default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oneliner_window_hours: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -103,12 +96,6 @@ impl Config {
|
||||
.unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS),
|
||||
)
|
||||
}
|
||||
|
||||
/// Window-hours parameter for `/api/oneliners`, with default.
|
||||
pub fn window_hours(&self) -> u32 {
|
||||
self.oneliner_window_hours
|
||||
.unwrap_or(DEFAULT_ONELINER_WINDOW_HOURS)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -121,7 +108,6 @@ mod tests {
|
||||
server_url: "https://doctate.example.org".into(),
|
||||
api_key: "sk-test-12345".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +152,6 @@ mod tests {
|
||||
std::fs::write(&path, "server_url = \"http://x\"\napi_key = \"y\"\n").unwrap();
|
||||
let cfg = Config::load_from(&path).unwrap().unwrap();
|
||||
assert_eq!(cfg.poll_interval(), Duration::from_secs(5));
|
||||
assert_eq!(cfg.window_hours(), 72);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -175,10 +160,8 @@ mod tests {
|
||||
server_url: "http://x".into(),
|
||||
api_key: "y".into(),
|
||||
oneliner_poll_interval_seconds: Some(10),
|
||||
oneliner_window_hours: Some(24),
|
||||
};
|
||||
assert_eq!(cfg.poll_interval(), Duration::from_secs(10));
|
||||
assert_eq!(cfg.window_hours(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -189,11 +172,9 @@ mod tests {
|
||||
server_url: "http://x".into(),
|
||||
api_key: "y".into(),
|
||||
oneliner_poll_interval_seconds: Some(3),
|
||||
oneliner_window_hours: Some(168),
|
||||
};
|
||||
original.save_to(&path).unwrap();
|
||||
let loaded = Config::load_from(&path).unwrap().unwrap();
|
||||
assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3));
|
||||
assert_eq!(loaded.oneliner_window_hours, Some(168));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,7 @@ pub mod startup;
|
||||
pub mod upload;
|
||||
|
||||
pub use case_store::{CaseMarker, CaseStore, CaseStoreError};
|
||||
pub use config::{
|
||||
Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
};
|
||||
pub use config::{Config, ConfigError, DEFAULT_POLL_INTERVAL_SECONDS};
|
||||
pub use footer_status::{FooterStatus, pick_footer_status};
|
||||
pub use pending_cleanup::cleanup_orphan_audio;
|
||||
pub use server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
|
||||
|
||||
@@ -46,7 +46,6 @@ pub struct SyncConfig {
|
||||
pub server_url: String,
|
||||
pub api_key: String,
|
||||
pub poll_interval: Duration,
|
||||
pub window_hours: u32,
|
||||
}
|
||||
|
||||
/// Three-state observable: what the worker is currently doing.
|
||||
@@ -194,7 +193,6 @@ async fn run_loop(
|
||||
) {
|
||||
info!(
|
||||
interval_s = config.poll_interval.as_secs(),
|
||||
window_h = config.window_hours,
|
||||
"server sync worker started"
|
||||
);
|
||||
|
||||
@@ -409,10 +407,9 @@ pub(crate) async fn poll_once(
|
||||
etag: &mut Option<String>,
|
||||
) -> Result<PollOutcome, PollError> {
|
||||
let url = format!(
|
||||
"{}{}?hours={}",
|
||||
"{}{}",
|
||||
config.server_url.trim_end_matches('/'),
|
||||
ONELINERS_PATH,
|
||||
config.window_hours
|
||||
);
|
||||
|
||||
let mut req = http.get(&url).header(API_KEY_HEADER, &config.api_key);
|
||||
@@ -502,7 +499,6 @@ mod tests {
|
||||
server_url: server.uri(),
|
||||
api_key: TEST_KEY.into(),
|
||||
poll_interval: Duration::from_millis(50),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user