feat(kanban): persist canonical acknowledgements

req: sync/001\nreq: sync/005\nreq: sync/007\nreq: sync/008\nreq: sync/013
This commit is contained in:
slhx agent
2026-07-13 16:41:18 +02:00
parent 599d1b838d
commit 0a7c380187
3 changed files with 316 additions and 32 deletions
+185 -30
View File
@@ -14,10 +14,13 @@ 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 serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -27,8 +30,12 @@ const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"),
struct AppState {
board: Mutex<BoardState>,
sync: Mutex<SyncState>,
sync_store: Option<SyncStore>,
}
#[derive(Clone)]
struct SyncStore(PathBuf);
#[derive(Default)]
struct SyncState {
next_sequence: u64,
@@ -43,7 +50,10 @@ struct CommandId(String);
impl CommandId {
fn parse(value: Option<&String>) -> Result<Self, SyncRejection> {
let value = value.map(String::as_str).unwrap_or_default();
Self::parse_str(value.map(String::as_str).unwrap_or_default())
}
fn parse_str(value: &str) -> Result<Self, SyncRejection> {
if value.is_empty()
|| value.len() > 128
|| !value.bytes().all(|byte| {
@@ -71,6 +81,7 @@ enum SyncRejection {
BadRequest(&'static str),
Conflict(&'static str),
Transient,
Storage,
}
impl IntoResponse for SyncRejection {
@@ -79,11 +90,125 @@ impl IntoResponse for SyncRejection {
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, error),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Transient => (StatusCode::SERVICE_UNAVAILABLE, "transient sync failure"),
Self::Storage => (StatusCode::INTERNAL_SERVER_ERROR, "sync storage failed"),
};
(status, Json(serde_json::json!({ "error": error }))).into_response()
}
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PersistedSync {
schema_version: u8,
next_sequence: u64,
acknowledgements: Vec<PersistedAcknowledgement>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PersistedAcknowledgement {
command_id: String,
server_sequence: u64,
card_id: u64,
}
impl SyncStore {
fn load(&self) -> Result<SyncState, String> {
if !self.0.exists() {
return Ok(SyncState {
next_sequence: 1,
..SyncState::default()
});
}
let bytes =
fs::read(&self.0).map_err(|error| format!("read {}: {error}", self.0.display()))?;
let persisted: PersistedSync = serde_json::from_slice(&bytes)
.map_err(|error| format!("decode {}: {error}", self.0.display()))?;
if persisted.schema_version != 1 || persisted.next_sequence == 0 {
return Err(format!("unsupported sync store {}", self.0.display()));
}
let mut acknowledgements = BTreeMap::new();
for stored in persisted.acknowledgements {
let command_id = CommandId::parse_str(&stored.command_id)
.map_err(|_| format!("invalid command id in {}", self.0.display()))?;
if stored.server_sequence == 0 || stored.card_id == 0 {
return Err(format!("invalid acknowledgement in {}", self.0.display()));
}
let acknowledgement = SyncAcknowledgement {
command_id: stored.command_id,
server_sequence: stored.server_sequence,
card_id: stored.card_id,
canonical_column: "done",
status: "accepted",
};
if acknowledgements
.insert(command_id, acknowledgement)
.is_some()
{
return Err(format!("duplicate command id in {}", self.0.display()));
}
}
Ok(SyncState {
next_sequence: persisted.next_sequence,
acknowledgements,
..SyncState::default()
})
}
fn persist(
&self,
sync: &SyncState,
acknowledgement: &SyncAcknowledgement,
) -> Result<(), String> {
let mut acknowledgements = sync
.acknowledgements
.values()
.map(PersistedAcknowledgement::from)
.collect::<Vec<_>>();
acknowledgements.push(PersistedAcknowledgement::from(acknowledgement));
acknowledgements.sort_by_key(|item| item.server_sequence);
let persisted = PersistedSync {
schema_version: 1,
next_sequence: acknowledgement.server_sequence + 1,
acknowledgements,
};
let bytes = serde_json::to_vec_pretty(&persisted)
.map_err(|error| format!("encode {}: {error}", self.0.display()))?;
if let Some(parent) = self.0.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("create {}: {error}", parent.display()))?;
}
let temporary = self.0.with_extension("tmp");
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)
.map_err(|error| format!("open {}: {error}", temporary.display()))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| format!("write {}: {error}", temporary.display()))?;
fs::rename(&temporary, &self.0)
.map_err(|error| format!("replace {}: {error}", self.0.display()))?;
if let Some(parent) = self.0.parent() {
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| format!("sync {}: {error}", parent.display()))?;
}
Ok(())
}
}
impl From<&SyncAcknowledgement> for PersistedAcknowledgement {
fn from(value: &SyncAcknowledgement) -> Self {
Self {
command_id: value.command_id.clone(),
server_sequence: value.server_sequence,
card_id: value.card_id,
}
}
}
#[derive(Default, Clone)]
struct BoardState {
next_id: u64,
@@ -157,32 +282,33 @@ struct Presence {
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {
board: Mutex::new(BoardState {
next_id: 4,
cards: vec![
Card {
id: 1,
title: "Write requirements".into(),
column: 0,
},
Card {
id: 2,
title: "Build browser example".into(),
column: 1,
},
Card {
id: 3,
title: "Verify with HTTP".into(),
column: 2,
},
],
}),
sync: Mutex::new(SyncState {
let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE")
.map(PathBuf::from)
.map(SyncStore);
let mut sync = sync_store
.as_ref()
.map(SyncStore::load)
.transpose()
.unwrap_or_else(|error| panic!("cannot start with sync store: {error}"))
.unwrap_or_else(|| SyncState {
next_sequence: 1,
fail_first_upload: std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some(),
..SyncState::default()
}),
});
sync.fail_first_upload = std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some();
let mut board = initial_board();
for acknowledgement in sync.acknowledgements.values() {
if let Some(card) = board
.cards
.iter_mut()
.find(|card| card.id == acknowledgement.card_id)
{
card.column = 2;
}
}
let state = Arc::new(AppState {
board: Mutex::new(board),
sync: Mutex::new(sync),
sync_store,
});
let app = Router::new()
@@ -203,6 +329,29 @@ async fn main() {
axum::serve(listener, app).await.unwrap();
}
fn initial_board() -> BoardState {
BoardState {
next_id: 4,
cards: vec![
Card {
id: 1,
title: "Write requirements".into(),
column: 0,
},
Card {
id: 2,
title: "Build browser example".into(),
column: 1,
},
Card {
id: 3,
title: "Verify with HTTP".into(),
column: 2,
},
],
}
}
// req: examples/001 req: component/003
async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl IntoResponse {
let board = state.board.lock().unwrap().clone();
@@ -289,12 +438,11 @@ async fn sync_command(
}
let mut board = state.board.lock().unwrap();
let card = board
let card_index = board
.cards
.iter_mut()
.find(|card| card.id == card_id)
.iter()
.position(|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,
@@ -302,6 +450,13 @@ async fn sync_command(
canonical_column: "done",
status: "accepted",
};
if let Some(store) = &state.sync_store {
store.persist(&sync, &acknowledgement).map_err(|error| {
eprintln!("sync persistence failed: {error}");
SyncRejection::Storage
})?;
}
board.cards[card_index].column = 2;
sync.next_sequence += 1;
sync.acknowledgements
.insert(command_id, acknowledgement.clone());