3ef84e7331
req: sync/020 req: auth/005 req: security/004 req: operations/002
1048 lines
32 KiB
Rust
1048 lines
32 KiB
Rust
use axum::extract::{Query, State};
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use futures_util::{stream, StreamExt};
|
|
use hemplate::Hemplate;
|
|
use hemx::{Html, IntoEffect};
|
|
use hemx_axum::{
|
|
interactions, runtime_js, runtime_js_path, sse, DispatchRegistry, DispatchRejection,
|
|
EffectResponse, InteractionRequest, PageRequest, PageResponse,
|
|
};
|
|
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::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
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;
|
|
|
|
const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")];
|
|
|
|
#[derive(Default)]
|
|
struct AppState {
|
|
board: Mutex<BoardState>,
|
|
sync: Mutex<SyncState>,
|
|
sync_store: Option<SyncStore>,
|
|
sync_sessions: SyncSessionTokens,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SyncSessionTokens {
|
|
alice_alpha_editor: Option<String>,
|
|
bob_alpha_viewer: Option<String>,
|
|
carol_beta_editor: Option<String>,
|
|
}
|
|
|
|
impl SyncSessionTokens {
|
|
fn from_env() -> Self {
|
|
fn token(name: &str) -> Option<String> {
|
|
std::env::var(name).ok().filter(|value| !value.is_empty())
|
|
}
|
|
Self {
|
|
alice_alpha_editor: token("HEMX_KANBAN_SESSION_ALICE_ALPHA_EDITOR"),
|
|
bob_alpha_viewer: token("HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER"),
|
|
carol_beta_editor: token("HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR"),
|
|
}
|
|
}
|
|
|
|
fn is_configured(&self) -> bool {
|
|
self.alice_alpha_editor.is_some()
|
|
|| self.bob_alpha_viewer.is_some()
|
|
|| self.carol_beta_editor.is_some()
|
|
}
|
|
|
|
fn matches(expected: &Option<String>, candidate: &str) -> bool {
|
|
let Some(expected) = expected else {
|
|
return false;
|
|
};
|
|
if expected.len() != candidate.len() {
|
|
return false;
|
|
}
|
|
expected
|
|
.bytes()
|
|
.zip(candidate.bytes())
|
|
.fold(0_u8, |difference, (left, right)| {
|
|
difference | (left ^ right)
|
|
})
|
|
== 0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SyncStore(PathBuf);
|
|
|
|
#[derive(Default)]
|
|
struct SyncState {
|
|
next_sequence: u64,
|
|
acknowledgements: BTreeMap<CommandId, SyncAcknowledgement>,
|
|
retained_after: u64,
|
|
reconnects: BTreeMap<String, u64>,
|
|
transient_failure_limit: u8,
|
|
transient_failures: BTreeMap<CommandId, u8>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
struct CommandId(String);
|
|
|
|
impl CommandId {
|
|
fn parse(value: Option<&String>) -> Result<Self, SyncRejection> {
|
|
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| {
|
|
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.')
|
|
})
|
|
{
|
|
return Err(SyncRejection::BadRequest("invalid command_id"));
|
|
}
|
|
Ok(Self(value.to_owned()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum CanonicalColumn {
|
|
Todo,
|
|
Doing,
|
|
Done,
|
|
}
|
|
|
|
impl CanonicalColumn {
|
|
fn parse(value: Option<&String>) -> Result<Self, SyncRejection> {
|
|
match value.map(String::as_str).unwrap_or("done") {
|
|
"todo" => Ok(Self::Todo),
|
|
"doing" => Ok(Self::Doing),
|
|
"done" => Ok(Self::Done),
|
|
_ => Err(SyncRejection::BadRequest("invalid column")),
|
|
}
|
|
}
|
|
|
|
fn index(self) -> usize {
|
|
match self {
|
|
Self::Todo => 0,
|
|
Self::Doing => 1,
|
|
Self::Done => 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SyncAcknowledgement {
|
|
command_id: String,
|
|
server_sequence: u64,
|
|
card_id: u64,
|
|
canonical_column: CanonicalColumn,
|
|
status: &'static str,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SyncSnapshot {
|
|
schema_version: u8,
|
|
server_sequence: u64,
|
|
cards: Vec<SnapshotCard>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SnapshotCard {
|
|
id: u64,
|
|
column: CanonicalColumn,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum SyncRejection {
|
|
BadRequest(&'static str),
|
|
Unauthorized(&'static str),
|
|
Forbidden(&'static str),
|
|
Conflict(&'static str),
|
|
Transient,
|
|
Storage,
|
|
}
|
|
|
|
impl IntoResponse for SyncRejection {
|
|
fn into_response(self) -> axum::response::Response {
|
|
let (status, kind, error) = match self {
|
|
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, "invalid-command", error),
|
|
Self::Unauthorized(error) => (StatusCode::UNAUTHORIZED, "authorization-denial", error),
|
|
Self::Forbidden(error) => (StatusCode::FORBIDDEN, "authorization-denial", error),
|
|
Self::Conflict(error) => (StatusCode::CONFLICT, "command-conflict", error),
|
|
Self::Transient => (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"transport-failure",
|
|
"transient sync failure",
|
|
),
|
|
Self::Storage => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"storage-failure",
|
|
"sync storage failed",
|
|
),
|
|
};
|
|
(
|
|
status,
|
|
Json(serde_json::json!({ "kind": kind, "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,
|
|
canonical_column: CanonicalColumn,
|
|
}
|
|
|
|
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: stored.canonical_column,
|
|
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,
|
|
canonical_column: value.canonical_column,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct BoardState {
|
|
next_id: u64,
|
|
cards: Vec<Card>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Card {
|
|
id: u64,
|
|
title: String,
|
|
column: usize,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct AppShell {
|
|
runtime_src: &'static str,
|
|
body: Html,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct SyncShell {
|
|
runtime_src: &'static str,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct BoardColumns {
|
|
columns: Vec<BoardColumn>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct BoardColumn {
|
|
title: &'static str,
|
|
cards: Vec<BoardCard>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct BoardCard {
|
|
id: u64,
|
|
title: String,
|
|
left_disabled: bool,
|
|
right_disabled: bool,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct Board {
|
|
options: Html,
|
|
board: Html,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ColumnOptions {
|
|
options: Vec<ColumnOption>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ColumnOption {
|
|
id: &'static str,
|
|
title: &'static str,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct Presence {
|
|
count: u64,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
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,
|
|
..SyncState::default()
|
|
});
|
|
sync.retained_after = std::env::var("HEMX_KANBAN_RETAINED_AFTER")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.unwrap_or_default();
|
|
sync.transient_failure_limit = std::env::var("HEMX_KANBAN_SYNC_FAILURES")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u8>().ok())
|
|
.unwrap_or_else(|| u8::from(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 = acknowledgement.canonical_column.index();
|
|
}
|
|
}
|
|
let state = Arc::new(AppState {
|
|
board: Mutex::new(board),
|
|
sync: Mutex::new(sync),
|
|
sync_store,
|
|
sync_sessions: SyncSessionTokens::from_env(),
|
|
});
|
|
|
|
let app = Router::new()
|
|
.route("/", get(home).post(interact))
|
|
.route("/events", get(events))
|
|
.route("/sync-demo", get(sync_demo))
|
|
.route("/sync.js", get(sync_js))
|
|
.route("/sync/context", get(sync_context))
|
|
.route("/sync/commands", post(sync_command))
|
|
.route("/sync/acknowledgements", get(sync_acknowledgements))
|
|
.route("/sync/snapshot", get(sync_snapshot))
|
|
.route(runtime_js_path(), get(runtime))
|
|
.with_state(state);
|
|
|
|
let addr = std::env::var("HEMX_KANBAN_ADDR")
|
|
.map(|value| value.parse::<SocketAddr>().expect("valid HEMX_KANBAN_ADDR"))
|
|
.unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3001)));
|
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
|
println!("hemx Kanban example: http://{addr}");
|
|
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();
|
|
request
|
|
.page_html(page_html(&board), shell)
|
|
.title("hemx Kanban")
|
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
async fn runtime() -> impl IntoResponse {
|
|
runtime_js()
|
|
}
|
|
|
|
async fn sync_demo() -> impl IntoResponse {
|
|
PageResponse::full(
|
|
ui::page(&SyncShell {
|
|
runtime_src: runtime_js_path(),
|
|
})
|
|
.into_string(),
|
|
)
|
|
.title("hemx Kanban sync")
|
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
async fn sync_js() -> impl IntoResponse {
|
|
(
|
|
[("content-type", "text/javascript; charset=utf-8")],
|
|
include_str!("../static/sync.js"),
|
|
)
|
|
}
|
|
|
|
async fn interact(
|
|
State(state): State<Arc<AppState>>,
|
|
request: InteractionRequest,
|
|
) -> Result<EffectResponse, DispatchRejection> {
|
|
request.dispatch(registry(state))
|
|
}
|
|
|
|
// req: push/001 req: push/003 req: examples/001
|
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
|
if params.contains_key("once") {
|
|
let effect = board::presence.put(&Presence { count: 1 });
|
|
return sse(stream::iter([Ok::<_, Infallible>(
|
|
effect.into_batch(ui::BUILD_FINGERPRINT),
|
|
)])
|
|
.boxed());
|
|
}
|
|
|
|
let batches = stream::unfold(1_u64, |count| async move {
|
|
tokio::time::sleep(Duration::from_secs(4)).await;
|
|
let effect = board::presence.put(&Presence { count });
|
|
Some((
|
|
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
|
count + 1,
|
|
))
|
|
})
|
|
.boxed();
|
|
sse(batches)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct CurrentSyncPrincipal {
|
|
principal: &'static str,
|
|
tenant: &'static str,
|
|
can_replay: bool,
|
|
}
|
|
|
|
fn current_sync_principal(
|
|
headers: &HeaderMap,
|
|
sessions: &SyncSessionTokens,
|
|
) -> Result<CurrentSyncPrincipal, SyncRejection> {
|
|
let session = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|cookies| {
|
|
cookies.split(';').find_map(|cookie| {
|
|
cookie
|
|
.trim()
|
|
.strip_prefix("hemx_kanban_session=")
|
|
.map(str::trim)
|
|
})
|
|
});
|
|
let Some(session) = session else {
|
|
if sessions.is_configured() {
|
|
return Err(SyncRejection::Unauthorized(
|
|
"current sync session is required",
|
|
));
|
|
}
|
|
return Ok(CurrentSyncPrincipal {
|
|
principal: "demo",
|
|
tenant: "demo",
|
|
can_replay: true,
|
|
});
|
|
};
|
|
if SyncSessionTokens::matches(&sessions.alice_alpha_editor, session) {
|
|
return Ok(CurrentSyncPrincipal {
|
|
principal: "alice",
|
|
tenant: "alpha",
|
|
can_replay: true,
|
|
});
|
|
}
|
|
if SyncSessionTokens::matches(&sessions.bob_alpha_viewer, session) {
|
|
return Ok(CurrentSyncPrincipal {
|
|
principal: "bob",
|
|
tenant: "alpha",
|
|
can_replay: false,
|
|
});
|
|
}
|
|
if SyncSessionTokens::matches(&sessions.carol_beta_editor, session) {
|
|
return Ok(CurrentSyncPrincipal {
|
|
principal: "carol",
|
|
tenant: "beta",
|
|
can_replay: true,
|
|
});
|
|
}
|
|
Err(SyncRejection::Unauthorized(
|
|
"current sync session is invalid",
|
|
))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SyncContext {
|
|
account_partition: String,
|
|
}
|
|
|
|
// req: sync/020 req: auth/005
|
|
async fn sync_context(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<SyncContext>, SyncRejection> {
|
|
let principal = current_sync_principal(&headers, &state.sync_sessions)?;
|
|
Ok(Json(SyncContext {
|
|
account_partition: format!("{}:{}", principal.tenant, principal.principal),
|
|
}))
|
|
}
|
|
|
|
fn authorize_sync_replay(
|
|
principal: CurrentSyncPrincipal,
|
|
card_id: u64,
|
|
) -> Result<(), SyncRejection> {
|
|
if principal.principal == "demo" && principal.tenant == "demo" {
|
|
return Ok(());
|
|
}
|
|
if !principal.can_replay {
|
|
return Err(SyncRejection::Forbidden(
|
|
"current principal cannot replay commands",
|
|
));
|
|
}
|
|
let card_tenant = if card_id == 2 { "beta" } else { "alpha" };
|
|
if principal.tenant != card_tenant {
|
|
return Err(SyncRejection::Forbidden(
|
|
"current tenant cannot access command target",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// req: sync/001 req: sync/008 req: sync/012 req: sync/019 req: security/004
|
|
async fn sync_command(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
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 canonical_column = CanonicalColumn::parse(params.get("column"))?;
|
|
let principal = current_sync_principal(&headers, &state.sync_sessions)?;
|
|
authorize_sync_replay(principal, card_id)?;
|
|
|
|
let mut sync = state.sync.lock().unwrap();
|
|
if let Some(existing) = sync.acknowledgements.get(&command_id) {
|
|
if existing.card_id != card_id || existing.canonical_column != canonical_column {
|
|
return Err(SyncRejection::Conflict(
|
|
"command_id was already used for a different payload",
|
|
));
|
|
}
|
|
return Ok(Json(existing.clone()));
|
|
}
|
|
let transient_failure_limit = sync.transient_failure_limit;
|
|
if transient_failure_limit > 0 {
|
|
let failures = sync
|
|
.transient_failures
|
|
.entry(command_id.clone())
|
|
.or_default();
|
|
if *failures < transient_failure_limit {
|
|
*failures += 1;
|
|
return Err(SyncRejection::Transient);
|
|
}
|
|
}
|
|
|
|
let mut board = state.board.lock().unwrap();
|
|
let card_index = board
|
|
.cards
|
|
.iter()
|
|
.position(|card| card.id == card_id)
|
|
.ok_or(SyncRejection::BadRequest("unknown card_id"))?;
|
|
let acknowledgement = SyncAcknowledgement {
|
|
command_id: command_id.0.clone(),
|
|
server_sequence: sync.next_sequence,
|
|
card_id,
|
|
canonical_column,
|
|
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 = canonical_column.index();
|
|
sync.next_sequence += 1;
|
|
sync.acknowledgements
|
|
.insert(command_id, acknowledgement.clone());
|
|
Ok(Json(acknowledgement))
|
|
}
|
|
|
|
// req: sync/007 req: sync/020
|
|
async fn sync_snapshot(State(state): State<Arc<AppState>>) -> Json<SyncSnapshot> {
|
|
let board = state.board.lock().unwrap();
|
|
let sync = state.sync.lock().unwrap();
|
|
let cards = board
|
|
.cards
|
|
.iter()
|
|
.map(|card| SnapshotCard {
|
|
id: card.id,
|
|
column: canonical_column(card.column),
|
|
})
|
|
.collect();
|
|
Json(SyncSnapshot {
|
|
schema_version: 1,
|
|
server_sequence: sync.next_sequence.saturating_sub(1),
|
|
cards,
|
|
})
|
|
}
|
|
|
|
fn canonical_column(column: usize) -> CanonicalColumn {
|
|
match column {
|
|
1 => CanonicalColumn::Doing,
|
|
2 => CanonicalColumn::Done,
|
|
_ => CanonicalColumn::Todo,
|
|
}
|
|
}
|
|
|
|
// 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 first_available = sync
|
|
.acknowledgements
|
|
.values()
|
|
.filter(|acknowledgement| acknowledgement.server_sequence > sync.retained_after)
|
|
.map(|acknowledgement| acknowledgement.server_sequence)
|
|
.min();
|
|
let latest = sync.next_sequence.saturating_sub(1);
|
|
let history_missing = after < latest
|
|
&& first_available.is_none_or(|first_sequence| after.saturating_add(1) < first_sequence);
|
|
let events = if history_missing {
|
|
vec![Ok(Event::default()
|
|
.id(latest.to_string())
|
|
.event("snapshot-required")
|
|
.json_data(serde_json::json!({
|
|
"after": after,
|
|
"firstAvailable": first_available,
|
|
"latest": latest,
|
|
"snapshotUrl": "/sync/snapshot",
|
|
}))
|
|
.expect("serializable missing history event"))]
|
|
} else {
|
|
sync.acknowledgements
|
|
.values()
|
|
.filter(|acknowledgement| {
|
|
acknowledgement.server_sequence > after
|
|
&& acknowledgement.server_sequence > sync.retained_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, {
|
|
let state = state.clone();
|
|
move |form| {
|
|
// req: examples/001 req: form/002
|
|
let title = form.value("title").unwrap_or("").trim();
|
|
let column = parse_column(form.value("column"));
|
|
let mut board = state.board.lock().unwrap();
|
|
if !title.is_empty() {
|
|
let id = board.next_id;
|
|
board.next_id += 1;
|
|
board.cards.push(Card {
|
|
id,
|
|
title: title.into(),
|
|
column,
|
|
});
|
|
}
|
|
board_effects(&board, "Card added")
|
|
}
|
|
})
|
|
.on(card_board::move_left, {
|
|
let state = state.clone();
|
|
move |form| {
|
|
// req: examples/001 req: list/003
|
|
let mut board = state.board.lock().unwrap();
|
|
let moved = update_card(&mut board, form.parse("card_id"), |card| {
|
|
card.column = card.column.saturating_sub(1);
|
|
});
|
|
board_effects(
|
|
&board,
|
|
if moved {
|
|
"Card moved left"
|
|
} else {
|
|
"Card not found"
|
|
},
|
|
)
|
|
}
|
|
})
|
|
.on(card_board::move_right, {
|
|
let state = state.clone();
|
|
move |form| {
|
|
// req: examples/001 req: list/003
|
|
let mut board = state.board.lock().unwrap();
|
|
let moved = update_card(&mut board, form.parse("card_id"), |card| {
|
|
card.column = (card.column + 1).min(COLUMNS.len() - 1);
|
|
});
|
|
board_effects(
|
|
&board,
|
|
if moved {
|
|
"Card moved right"
|
|
} else {
|
|
"Card not found"
|
|
},
|
|
)
|
|
}
|
|
})
|
|
.on(card_board::delete_card, {
|
|
let state = state.clone();
|
|
move |form| {
|
|
// req: examples/001 req: list/003
|
|
let mut board = state.board.lock().unwrap();
|
|
let before = board.cards.len();
|
|
if let Some(id) = form.parse::<u64>("card_id") {
|
|
board.cards.retain(|card| card.id != id);
|
|
}
|
|
board_effects(
|
|
&board,
|
|
if board.cards.len() < before {
|
|
"Card deleted"
|
|
} else {
|
|
"Card not found"
|
|
},
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
|
|
(
|
|
board::board.put(&board_view(board)),
|
|
board::notice.text(notice),
|
|
board::create_card_form.clear(),
|
|
)
|
|
}
|
|
|
|
fn update_card(
|
|
board: &mut BoardState,
|
|
card_id: Option<u64>,
|
|
update: impl FnOnce(&mut Card),
|
|
) -> bool {
|
|
let Some(id) = card_id else {
|
|
return false;
|
|
};
|
|
let Some(card) = board.cards.iter_mut().find(|card| card.id == id) else {
|
|
return false;
|
|
};
|
|
update(card);
|
|
true
|
|
}
|
|
|
|
fn parse_column(value: Option<&str>) -> usize {
|
|
let id = value.unwrap_or(COLUMNS[0].0);
|
|
COLUMNS
|
|
.iter()
|
|
.position(|(column_id, _)| *column_id == id)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn page_html(board: &BoardState) -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
board_ui::page(&Board {
|
|
options: render_options(),
|
|
board: ui::page(&board_view(board)),
|
|
})
|
|
}
|
|
|
|
fn shell(body: Html) -> Html {
|
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
|
|
ui::page(&AppShell {
|
|
runtime_src: runtime_js_path(),
|
|
body,
|
|
})
|
|
}
|
|
|
|
fn render_options() -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
ui::page(&ColumnOptions {
|
|
options: COLUMNS
|
|
.iter()
|
|
.map(|(id, title)| ColumnOption { id, title })
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn board_view(board: &BoardState) -> BoardColumns {
|
|
// req: html_safety/002 req: view/001
|
|
BoardColumns {
|
|
columns: COLUMNS
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, (_, title))| BoardColumn {
|
|
title,
|
|
cards: board
|
|
.cards
|
|
.iter()
|
|
.filter(|card| card.column == idx)
|
|
.map(render_card)
|
|
.collect(),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn render_card(card: &Card) -> BoardCard {
|
|
BoardCard {
|
|
id: card.id,
|
|
title: card.title.clone(),
|
|
left_disabled: card.column == 0,
|
|
right_disabled: card.column + 1 == COLUMNS.len(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use hemx_test::{
|
|
class_child_selector, disabled_button_selector, element_class_selector,
|
|
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
|
|
select_options_selector, small_text_selector, strong_text_selector,
|
|
};
|
|
use scraper::{Html, Selector};
|
|
|
|
fn selector(value: &str) -> Selector {
|
|
Selector::parse(value).expect("test selector parses")
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn kanban_page_is_composed_by_a_hemplate_view() {
|
|
let html = page_html(&BoardState::default());
|
|
assert!(!html.as_str().contains("__OPTIONS__"));
|
|
assert!(!html.as_str().contains("__BOARD__"));
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&root_element_selector("section", "kanban")))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&select_options_selector("column")))
|
|
.count(),
|
|
3
|
|
);
|
|
assert_eq!(
|
|
document.select(&selector(&form_selector("header"))).count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn board_payload_is_rendered_by_a_hemplate_view() {
|
|
let board = BoardState {
|
|
next_id: 2,
|
|
cards: vec![Card {
|
|
id: 1,
|
|
title: "<b>Compile checked</b>".to_owned(),
|
|
column: 0,
|
|
}],
|
|
};
|
|
|
|
let html = ui::page(&board_view(&board));
|
|
let document = Html::parse_fragment(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&class_child_selector(
|
|
"columns", "section", "column"
|
|
)))
|
|
.count(),
|
|
3
|
|
);
|
|
let card = document
|
|
.select(&selector(&keyed_selector("article.card", 1)))
|
|
.next()
|
|
.expect("card renders");
|
|
let title = card
|
|
.select(&selector(strong_text_selector()))
|
|
.next()
|
|
.expect("card title renders");
|
|
assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>");
|
|
assert!(card
|
|
.select(&selector(&escaped_markup_selector("b")))
|
|
.next()
|
|
.is_none());
|
|
assert_eq!(
|
|
card.select(&selector(disabled_button_selector())).count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn presence_payload_is_rendered_by_a_hemplate_view() {
|
|
let html = ui::page(&Presence { count: 7 });
|
|
let document = Html::parse_fragment(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&element_class_selector("span", "presence")))
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(small_text_selector()))
|
|
.next()
|
|
.map(|small| small.text().collect::<String>()),
|
|
Some("tick #7".to_owned())
|
|
);
|
|
}
|
|
}
|