arch: split ConfigRepository, extract polling, consolidate conversions, decouple protocol

- Value↔JSON: From impls on domain Value behind `json` feature, delete 4 duplicate converters
- ConfigRepository split into ConfigRepository (12), UserRepository (3), WidgetStateCache (2)
- polling orchestration moved from bootstrap to application::polling_service
- WidgetRenderer in client-domain owns scroll/cache, both clients use it
- network loop consolidated into client-application::run_connection_loop
- protocol crate drops domain dep, Wire↔Domain conversions move to adapters
This commit is contained in:
2026-06-19 18:12:50 +02:00
parent 1c854d127f
commit 7001b5e911
46 changed files with 1063 additions and 951 deletions

View File

@@ -1,6 +1,6 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId, WidgetState,
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
use std::collections::HashMap;
use std::sync::RwLock;
@@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore {
guard.remove(&id);
Ok(())
}
}
impl UserRepository for MemoryConfigStore {
type Error = MemoryConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
let guard = self
@@ -203,6 +207,10 @@ impl ConfigRepository for MemoryConfigStore {
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.len() as u32)
}
}
impl WidgetStateCache for MemoryConfigStore {
type Error = MemoryConfigError;
async fn save_widget_states(
&self,

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -10,7 +10,7 @@ use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId, WidgetState,
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
impl ConfigRepository for SqliteConfigStore {
@@ -79,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore {
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
self.save_theme_impl(theme).await
}
}
impl UserRepository for SqliteConfigStore {
type Error = SqliteConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
self.get_user_by_username_impl(username).await
@@ -91,6 +95,10 @@ impl ConfigRepository for SqliteConfigStore {
async fn count_users(&self) -> Result<u32, Self::Error> {
self.count_users_impl().await
}
}
impl WidgetStateCache for SqliteConfigStore {
type Error = SqliteConfigError;
async fn save_widget_states(
&self,

View File

@@ -44,56 +44,17 @@ impl SqliteConfigStore {
}
}
fn domain_value_to_json(v: &Value) -> serde_json::Value {
match v {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Number(n) => serde_json::json!(n),
Value::String(s) => serde_json::Value::String(s.clone()),
Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
}
Value::Object(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
}
}
fn json_value_to_domain(v: &serde_json::Value) -> Value {
match v {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s.clone()),
serde_json::Value::Array(arr) => {
Value::Array(arr.iter().map(json_value_to_domain).collect())
}
serde_json::Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| (k.clone(), json_value_to_domain(v)))
.collect(),
),
}
}
fn domain_state_to_json(state: &WidgetState) -> Result<String, serde_json::Error> {
let data: serde_json::Map<String, serde_json::Value> = state
.data
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.map(|(k, v)| (k.clone(), v.into()))
.collect();
serde_json::to_string(&data)
}
fn json_to_domain_state(json: &str) -> Result<WidgetState, serde_json::Error> {
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
let data: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), json_value_to_domain(v)))
.collect();
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
Ok(WidgetState { data, error: None })
}

View File

@@ -2,7 +2,7 @@ use config_sqlite::SqliteConfigStore;
use domain::{
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache,
};
use std::time::Duration;

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
application.workspace = true
api-types.workspace = true
axum.workspace = true

View File

@@ -4,7 +4,7 @@ mod routes;
use axum::Router;
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
UserRepository, WidgetStateCache, WidgetStateReader,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
@@ -38,8 +38,9 @@ impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
@@ -69,8 +70,9 @@ pub async fn serve<C, E, W, B, R, A, H>(
state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
use domain::{AuthPort, PasswordHashPort, UserRepository};
use serde::{Deserialize, Serialize};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
@@ -28,7 +28,7 @@ pub async fn login<C, E, W, B, R, A, H>(
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
A: AuthPort,
H: PasswordHashPort,
@@ -51,7 +51,7 @@ pub async fn register<C, E, W, B, R, A, H>(
Json(body): Json<LoginRequest>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
H: PasswordHashPort,
{
@@ -71,7 +71,7 @@ pub async fn auth_status<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<StatusResponse>, StatusCode>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
{
let count = state

View File

@@ -12,13 +12,14 @@ use axum::Router;
use axum::routing::{get, post};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
UserRepository, WidgetStateCache, WidgetStateReader,
};
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,

View File

@@ -31,7 +31,7 @@ where
));
}
let data = json_to_domain_value(body);
let data: domain::Value = body.into();
state
.events
@@ -41,22 +41,3 @@ where
Ok(StatusCode::OK)
}
fn json_to_domain_value(json: serde_json::Value) -> domain::Value {
match json {
serde_json::Value::Null => domain::Value::Null,
serde_json::Value::Bool(b) => domain::Value::Bool(b),
serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => domain::Value::String(s),
serde_json::Value::Array(arr) => {
domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect())
}
serde_json::Value::Object(obj) => {
let map = obj
.into_iter()
.map(|(k, v)| (k, json_to_domain_value(v)))
.collect();
domain::Value::Object(map)
}
}
}

View File

@@ -126,32 +126,10 @@ where
{
match state.widget_states.get_widget_state(id).await {
Some(ws) => {
let map: serde_json::Map<String, serde_json::Value> = ws
.data
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
let map: serde_json::Map<String, serde_json::Value> =
ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect();
Ok(Json(serde_json::Value::Object(map)))
}
None => Err(StatusCode::NOT_FOUND),
}
}
fn domain_value_to_json(v: &domain::Value) -> serde_json::Value {
match v {
domain::Value::Null => serde_json::Value::Null,
domain::Value::Bool(b) => serde_json::Value::Bool(*b),
domain::Value::Number(n) => serde_json::json!(n),
domain::Value::String(s) => serde_json::Value::String(s.clone()),
domain::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
}
domain::Value::Object(obj) => {
let map = obj
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View File

@@ -28,21 +28,6 @@ impl HttpJsonAdapter {
}
}
fn json_to_value(json: serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, json_to_value(v)))
.collect(),
),
}
}
impl DataSourcePort for HttpJsonAdapter {
type Error = HttpJsonError;
@@ -70,6 +55,6 @@ impl DataSourcePort for HttpJsonAdapter {
let resp = req.send().await.map_err(HttpJsonError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
Ok(json_to_value(json))
Ok(json.into())
}
}

View File

@@ -1,6 +1,7 @@
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireTheme, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster {
@@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster {
layout: &Layout,
widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_layout = layout_to_wire(&layout.root);
let wire_widgets: Vec<WidgetDescriptor> = widgets
.iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: hint.into(),
state: state.into(),
display_hint: display_hint_to_wire(hint),
state: widget_state_to_wire(state),
})
.collect();
@@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster {
.iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: hint.into(),
state: state.into(),
display_hint: display_hint_to_wire(hint),
state: widget_state_to_wire(state),
})
.collect();

View File

@@ -0,0 +1,103 @@
use domain::value_objects::{
AlignItems, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, LayoutNode,
Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind,
WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing,
WireVAlign, WireValue, WireWidgetError, WireWidgetState,
};
pub fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
pub fn widget_error_to_wire(e: &WidgetError) -> WireWidgetError {
match e {
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
}
}
pub fn widget_state_to_wire(s: &WidgetState) -> WireWidgetState {
WireWidgetState {
data: s
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: s.error.as_ref().map(widget_error_to_wire),
}
}
pub fn display_hint_to_wire(h: &DisplayHint) -> WireDisplayHint {
WireDisplayHint {
kind: match h.kind {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
},
h_align: match h.h_align {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
},
v_align: match h.v_align {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
},
}
}
pub fn layout_to_wire(n: &LayoutNode) -> WireLayoutNode {
match n {
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
direction: match c.direction {
Direction::Row => WireDirection::Row,
Direction::Column => WireDirection::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
},
children: c
.children
.iter()
.map(|ch| WireLayoutChild {
sizing: match ch.sizing {
Sizing::Fixed(px) => WireSizing::Fixed(px),
Sizing::Flex(w) => WireSizing::Flex(w),
},
node: layout_to_wire(&ch.node),
})
.collect(),
}),
}
}

View File

@@ -1,5 +1,6 @@
mod broadcaster;
mod client_tracker;
mod conversions;
mod error;
mod event_bus;
mod server;

View File

@@ -1,8 +1,9 @@
use crate::broadcaster::domain_theme_to_wire;
use crate::client_tracker::ClientTracker;
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{ConfigRepository, WidgetStateReader};
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
use protocol::{ServerMessage, WidgetDescriptor, encode};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
@@ -87,14 +88,14 @@ where
}
};
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_layout = layout_to_wire(&layout.root);
let mut wire_widgets = Vec::new();
for w in &widgets {
if let Some(s) = widget_states.get_widget_state(w.id).await {
wire_widgets.push(WidgetDescriptor {
id: w.id,
display_hint: (&w.display_hint).into(),
state: (&s).into(),
display_hint: display_hint_to_wire(&w.display_hint),
state: widget_state_to_wire(&s),
});
}
}