feat(kanban): acknowledge idempotent server command

req: sync/001\nreq: sync/005\nreq: sync/006\nreq: sync/007\nreq: sync/008\nreq: sync/012\nreq: sync/013
This commit is contained in:
slhx agent
2026-07-13 16:02:19 +02:00
parent 1d194e0f23
commit 687c64d078
5 changed files with 280 additions and 5 deletions
+148 -2
View File
@@ -1,7 +1,9 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use hemx::{Html, IntoEffect};
@@ -12,6 +14,7 @@ use hemx_axum::{
use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui};
use serde::Serialize;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -23,6 +26,58 @@ const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"),
#[derive(Default)]
struct AppState {
board: Mutex<BoardState>,
sync: Mutex<SyncState>,
}
#[derive(Default)]
struct SyncState {
next_sequence: u64,
acknowledgements: BTreeMap<CommandId, SyncAcknowledgement>,
reconnects: BTreeMap<String, u64>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct CommandId(String);
impl CommandId {
fn parse(value: Option<&String>) -> Result<Self, SyncRejection> {
let value = value.map(String::as_str).unwrap_or_default();
if value.is_empty()
|| value.len() > 128
|| !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.')
})
{
return Err(SyncRejection::BadRequest("invalid command_id"));
}
Ok(Self(value.to_owned()))
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncAcknowledgement {
command_id: String,
server_sequence: u64,
card_id: u64,
canonical_column: &'static str,
status: &'static str,
}
#[derive(Debug)]
enum SyncRejection {
BadRequest(&'static str),
Conflict(&'static str),
}
impl IntoResponse for SyncRejection {
fn into_response(self) -> axum::response::Response {
let (status, error) = match self {
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, error),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
};
(status, Json(serde_json::json!({ "error": error }))).into_response()
}
}
#[derive(Default, Clone)]
@@ -114,11 +169,17 @@ async fn main() {
},
],
}),
sync: Mutex::new(SyncState {
next_sequence: 1,
..SyncState::default()
}),
});
let app = Router::new()
.route("/", get(home).post(interact))
.route("/events", get(events))
.route("/sync/commands", post(sync_command))
.route("/sync/acknowledgements", get(sync_acknowledgements))
.route(runtime_js_path(), get(runtime))
.with_state(state);
@@ -172,6 +233,91 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
sse(batches)
}
// req: sync/001 req: sync/008 req: sync/012
async fn sync_command(
State(state): State<Arc<AppState>>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Json<SyncAcknowledgement>, SyncRejection> {
let command_id = CommandId::parse(params.get("command_id"))?;
let card_id = params
.get("card_id")
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.ok_or(SyncRejection::BadRequest("invalid card_id"))?;
let mut sync = state.sync.lock().unwrap();
if let Some(existing) = sync.acknowledgements.get(&command_id) {
if existing.card_id != card_id {
return Err(SyncRejection::Conflict(
"command_id was already used for a different payload",
));
}
return Ok(Json(existing.clone()));
}
let mut board = state.board.lock().unwrap();
let card = board
.cards
.iter_mut()
.find(|card| card.id == card_id)
.ok_or(SyncRejection::BadRequest("unknown card_id"))?;
card.column = 2;
let acknowledgement = SyncAcknowledgement {
command_id: command_id.0.clone(),
server_sequence: sync.next_sequence,
card_id,
canonical_column: "done",
status: "accepted",
};
sync.next_sequence += 1;
sync.acknowledgements
.insert(command_id, acknowledgement.clone());
Ok(Json(acknowledgement))
}
// req: sync/005 req: sync/006 req: sync/007 req: sync/013
async fn sync_acknowledgements(
State(state): State<Arc<AppState>>,
Query(params): Query<BTreeMap<String, String>>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> {
let after = params
.get("after")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_default();
let reconnect_key = params
.get("reconnect")
.filter(|value| !value.is_empty())
.cloned();
let mut sync = state.sync.lock().unwrap();
if let Some(key) = reconnect_key {
let attempts = sync.reconnects.entry(key).or_default();
*attempts += 1;
if *attempts == 1 {
return Sse::new(
stream::iter([Ok(Event::default()
.comment("reconnect")
.retry(Duration::from_millis(25)))])
.boxed(),
)
.keep_alive(KeepAlive::default());
}
}
let events = sync
.acknowledgements
.values()
.filter(|acknowledgement| acknowledgement.server_sequence > after)
.map(|acknowledgement| {
Ok(Event::default()
.id(acknowledgement.server_sequence.to_string())
.event("acknowledgement")
.json_data(acknowledgement)
.expect("serializable acknowledgement"))
})
.collect::<Vec<Result<Event, Infallible>>>();
Sse::new(stream::iter(events).boxed()).keep_alive(KeepAlive::default())
}
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
interactions(ui::BUILD_FINGERPRINT)
.on(board::create_card, {