First Commit, it compiles!
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
|
||||
.direnv/
|
||||
|
||||
*.db
|
||||
Generated
+2325
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "livevue-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
maud = { version = "0.26", features = ["axum"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dashmap = "6"
|
||||
anyhow = "1"
|
||||
tokio-stream = "0.1"
|
||||
@@ -0,0 +1,28 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Rust toolchain
|
||||
cargo
|
||||
rustc
|
||||
rustfmt
|
||||
clippy
|
||||
rust-analyzer
|
||||
|
||||
# Build dependencies
|
||||
pkg-config
|
||||
|
||||
# Additional tools
|
||||
cargo-watch
|
||||
cargo-edit
|
||||
|
||||
# Profiling tools
|
||||
cargo-flamegraph
|
||||
samply
|
||||
linuxPackages.perf
|
||||
|
||||
];
|
||||
|
||||
# Environment variables
|
||||
RUST_BACKTRACE = 1;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use crate::framework::context::RenderContext;
|
||||
use maud::{html, Markup};
|
||||
|
||||
/// Second page component: a simple "About" page.
|
||||
///
|
||||
/// This exists specifically to exercise:
|
||||
/// - The `tree` signal page routing (resolve_root match arm)
|
||||
/// - SPA-like navigation via tree signal mutation
|
||||
/// - Round-trip: TodoList → About → TodoList without full page reload
|
||||
///
|
||||
/// In a real app this might show user info, settings, etc.
|
||||
pub async fn about(_cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
Ok(html! {
|
||||
div.about-page {
|
||||
h1 { "About LiveVue.rs" }
|
||||
|
||||
nav {
|
||||
button.nav-link
|
||||
data-on-click="$tree = {page: 'TodoList'}; @post('/render')" {
|
||||
"Todos"
|
||||
}
|
||||
" | "
|
||||
span.nav-current { "About" }
|
||||
}
|
||||
|
||||
div.about-content {
|
||||
p {
|
||||
"LiveVue.rs is a reactive server-side rendering framework for Rust. "
|
||||
"The server renders full-page HTML on every state change. "
|
||||
"DataStar morphs it into the DOM."
|
||||
}
|
||||
p {
|
||||
"The architectural bet: "
|
||||
strong { "full rerender is cheap" }
|
||||
", so skip diffing, skip partial updates, skip client-side state management."
|
||||
}
|
||||
h2 { "v0 Status" }
|
||||
ul {
|
||||
li { "Single process, single node" }
|
||||
li { "SQLite for storage" }
|
||||
li { "Subscription keys collected (Phase 2 seam)" }
|
||||
li { "No cache, no auth, no WAL consumer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
pub mod about;
|
||||
pub mod queries;
|
||||
pub mod todo_list;
|
||||
|
||||
use crate::framework::connection::{ConnectionManager, SubscriptionRegistry};
|
||||
use crate::framework::context::{ComponentTree, RenderContext};
|
||||
use crate::framework::signal::SignalStore;
|
||||
use crate::framework::sse::{format_patch_elements, format_patch_signals};
|
||||
|
||||
use axum::extract::{Query as AxumQuery, State};
|
||||
use axum::http::header;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared application state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All shared state, wrapped in Arc for axum's State extractor.
|
||||
pub struct AppState {
|
||||
pub db: sqlx::SqlitePool,
|
||||
pub connections: ConnectionManager,
|
||||
pub subscriptions: SubscriptionRegistry,
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<AppState>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the axum Router with all routes.
|
||||
///
|
||||
/// Lesson from v0.0: handlers live here next to the components they call,
|
||||
/// not in a separate routes.rs with complex trait object signatures.
|
||||
pub fn router(state: SharedState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(initial_page_load))
|
||||
.route("/about", get(initial_about_load))
|
||||
.route("/sse", get(sse_handler))
|
||||
.route("/render", post(render_handler))
|
||||
.route("/action/add_todo", post(action_add_todo))
|
||||
.route("/action/toggle_todo", post(action_toggle_todo))
|
||||
.route("/action/delete_todo", post(action_delete_todo))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrap rendered component HTML in a full HTML document.
|
||||
///
|
||||
/// Lesson from v0.0: Maud can't handle DataStar's attribute syntax
|
||||
/// (e.g. `data-on-signal-change__debounce.300ms`). The document shell uses
|
||||
/// format! / raw string. Maud is for component interiors only.
|
||||
///
|
||||
/// DataStar v1 contract (verified against https://data-star.dev/reference):
|
||||
/// - `data-signals` — initialize signals (JS object literal syntax)
|
||||
/// - `data-on-signal-change` — fires when any signal changes
|
||||
/// - `__debounce.300ms` — modifier: debounce 300ms
|
||||
/// - `@post('/render')` — send all non-_ signals as JSON body
|
||||
/// - `@get('/sse?conn=...')` — open long-lived SSE pipe
|
||||
/// - `data-on-load` — fires when element enters DOM
|
||||
///
|
||||
/// KNOWN v0 QUIRK: Nav buttons use `$tree = {...}; @post('/render')`,
|
||||
/// which fires an immediate POST. The signal change handler then fires a
|
||||
/// second debounced POST 300ms later. This double-fire is harmless (same
|
||||
/// HTML both times) but wasteful. Phase 2 could fix this with request
|
||||
/// deduplication or by switching nav to only mutate the signal and relying
|
||||
/// solely on the auto-poster.
|
||||
fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LiveVue.rs Demo</title>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.8/bundles/datastar.js"></script>
|
||||
<style>
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"
|
||||
data-signals='{signals}'
|
||||
data-on-signal-change__debounce.300ms="@post('/render')"
|
||||
data-on-load="@get('/sse?conn={conn}')">
|
||||
{inner}
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
css = INLINE_CSS,
|
||||
signals = initial_signals,
|
||||
conn = conn_id,
|
||||
inner = inner_html,
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal CSS for the demo app. In a real app this would be a separate file.
|
||||
const INLINE_CSS: &str = r#"
|
||||
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fafafa; color: #333; }
|
||||
h1 { color: #2563eb; }
|
||||
nav { margin-bottom: 1.5rem; }
|
||||
.nav-link { background: none; border: none; color: #2563eb; cursor: pointer; text-decoration: underline; font-size: inherit; padding: 0; }
|
||||
.nav-current { font-weight: bold; }
|
||||
.add-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.add-form input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
.add-form button { padding: 0.5rem 1rem; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.add-form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.stats { color: #666; font-size: 0.9rem; }
|
||||
.todo-list { list-style: none; padding: 0; }
|
||||
.todo-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
||||
.todo-title { flex: 1; }
|
||||
.todo-title.completed { text-decoration: line-through; color: #999; }
|
||||
.delete-btn { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 1.2rem; padding: 0 0.25rem; }
|
||||
.empty { color: #999; font-style: italic; }
|
||||
.about-content p { line-height: 1.6; }
|
||||
"#;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Match a page name (from the `tree` signal) to its component function.
|
||||
///
|
||||
/// This is a plain match statement — no ComponentRegistry, no trait objects,
|
||||
/// no async lifetime gymnastics. The right call for a v0 with two pages.
|
||||
async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
||||
let markup = match cx.tree.page.as_str() {
|
||||
"TodoList" => todo_list::todo_list(cx).await?,
|
||||
"About" => about::about(cx).await?,
|
||||
other => maud::html! {
|
||||
div {
|
||||
h1 { "404 — Unknown page" }
|
||||
p { "No component registered for: " (other) }
|
||||
button.nav-link
|
||||
data-on-click="$tree = {page: 'TodoList'}; @post('/render')" {
|
||||
"Go to Todos"
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(markup.into_string())
|
||||
}
|
||||
|
||||
/// Shared render pipeline used by both initial page load and POST /render.
|
||||
///
|
||||
/// Returns `(rendered_html, subscription_keys, signals_json)`.
|
||||
async fn render_page(
|
||||
db: &sqlx::SqlitePool,
|
||||
conn_id: Uuid,
|
||||
signals: SignalStore,
|
||||
tree: ComponentTree,
|
||||
) -> anyhow::Result<(String, Vec<crate::framework::CacheKey>, String)> {
|
||||
let signals_json = signals.to_json_string();
|
||||
let mut cx = RenderContext::new(conn_id, signals, tree, db.clone());
|
||||
let html = resolve_and_render(&mut cx).await?;
|
||||
let keys = cx.take_subscription_keys();
|
||||
Ok((html, keys, signals_json))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /` — Initial page load for the todo list.
|
||||
async fn initial_page_load(State(state): State<SharedState>) -> impl IntoResponse {
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
// Build initial signals
|
||||
let tree = ComponentTree {
|
||||
page: "TodoList".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let signals = SignalStore::from_pairs(vec![
|
||||
("connId".into(), serde_json::json!(conn_id.to_string())),
|
||||
("tree".into(), serde_json::to_value(&tree).unwrap()),
|
||||
("newTodo".into(), serde_json::json!("")),
|
||||
]);
|
||||
|
||||
let initial_signals_attr = signals.to_data_signals_attr();
|
||||
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, _keys, _signals_json)) => {
|
||||
// Keys and signals_json are stored when SSE connects
|
||||
Html(document_shell(conn_id, &initial_signals_attr, &html))
|
||||
}
|
||||
Err(e) => Html(document_shell(
|
||||
conn_id,
|
||||
&initial_signals_attr,
|
||||
&format!("<p>Render error: {}</p>", e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /about` — Initial page load for the about page.
|
||||
async fn initial_about_load(State(state): State<SharedState>) -> impl IntoResponse {
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
let tree = ComponentTree {
|
||||
page: "About".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let signals = SignalStore::from_pairs(vec![
|
||||
("connId".into(), serde_json::json!(conn_id.to_string())),
|
||||
("tree".into(), serde_json::to_value(&tree).unwrap()),
|
||||
]);
|
||||
|
||||
let initial_signals_attr = signals.to_data_signals_attr();
|
||||
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, _keys, _signals_json)) => {
|
||||
Html(document_shell(conn_id, &initial_signals_attr, &html))
|
||||
}
|
||||
Err(e) => Html(document_shell(
|
||||
conn_id,
|
||||
&initial_signals_attr,
|
||||
&format!("<p>Render error: {}</p>", e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query params for the SSE endpoint.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SseParams {
|
||||
conn: Uuid,
|
||||
}
|
||||
|
||||
/// `GET /sse?conn=<uuid>` — Long-lived SSE pipe for server-initiated push.
|
||||
///
|
||||
/// In v0, nothing actively pushes events through this pipe (no invalidation
|
||||
/// events yet). It exists to:
|
||||
/// 1. Register the connection in ConnectionManager.
|
||||
/// 2. Keep the SSE connection alive (DataStar expects it).
|
||||
/// 3. Provide the Phase 2 seam for server-initiated rerenders.
|
||||
///
|
||||
/// On drop, the connection is cleaned up from both the ConnectionManager
|
||||
/// and the SubscriptionRegistry.
|
||||
async fn sse_handler(
|
||||
State(state): State<SharedState>,
|
||||
AxumQuery(params): AxumQuery<SseParams>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
let conn_id = params.conn;
|
||||
|
||||
// Create a channel for sending events to this connection
|
||||
let (tx, rx) = mpsc::channel::<String>(32);
|
||||
|
||||
// Clone sender for the cleanup watcher. Sender::closed() resolves when
|
||||
// the receiver is dropped (i.e. client disconnects and the SSE stream
|
||||
// drops). This is the proper cleanup mechanism — no arbitrary sleep.
|
||||
let tx_watch = tx.clone();
|
||||
|
||||
// Register the connection
|
||||
state.connections.insert(conn_id, tx);
|
||||
|
||||
// Spawn cleanup task: waits for the receiver to drop (client disconnect),
|
||||
// then removes the connection from all registries.
|
||||
let state_cleanup = state.clone();
|
||||
tokio::spawn(async move {
|
||||
tx_watch.closed().await;
|
||||
state_cleanup.connections.remove(&conn_id);
|
||||
state_cleanup.subscriptions.remove_connection(&conn_id);
|
||||
});
|
||||
|
||||
// Convert the receiver into an SSE stream.
|
||||
// DESIGN NOTE: For v0, the SSE pipe is mostly idle — POST /render
|
||||
// returns SSE directly in its response, not through this pipe. This
|
||||
// channel exists for Phase 2 server-initiated pushes (data invalidation).
|
||||
// Messages sent via the channel should be plain content strings (not
|
||||
// pre-formatted SSE); axum's Sse wrapper handles the SSE framing.
|
||||
let stream = ReceiverStream::new(rx);
|
||||
let event_stream = tokio_stream::StreamExt::map(stream, |msg| {
|
||||
Ok(Event::default().data(msg))
|
||||
});
|
||||
|
||||
Sse::new(event_stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// `POST /render` — Signal-triggered rerender.
|
||||
///
|
||||
/// DataStar sends all non-`_` signals as JSON body when any signal changes
|
||||
/// (triggered by `data-on-signal-change` + `@post('/render')`).
|
||||
///
|
||||
/// Returns `Content-Type: text/event-stream` with a single
|
||||
/// `datastar-patch-elements` event containing the full page HTML.
|
||||
async fn render_handler(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let signals = SignalStore::from_json(body);
|
||||
|
||||
// Extract connection id
|
||||
let conn_id: Uuid = signals
|
||||
.get("connId")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
|
||||
// Extract component tree
|
||||
let tree: ComponentTree = signals
|
||||
.get("tree")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, keys, signals_json)) => {
|
||||
// Update connection state
|
||||
state.connections.update_signals(&conn_id, signals_json);
|
||||
state.subscriptions.update(conn_id, keys);
|
||||
|
||||
let sse_body = format_patch_elements(&html);
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||
sse_body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let error_html = format!(r#"<div id="app"><p>Render error: {}</p></div>"#, e);
|
||||
let sse_body = format_patch_elements(&error_html);
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||
sse_body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query params for action endpoints that need an entity id.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ActionIdParams {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Helper: extract common fields from signal body, run a mutation, then rerender.
|
||||
///
|
||||
/// Returns an SSE response with the rerendered page.
|
||||
async fn action_then_render(
|
||||
state: &AppState,
|
||||
body: serde_json::Value,
|
||||
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
||||
patch_signals: Option<&str>,
|
||||
) -> impl IntoResponse {
|
||||
let signals = SignalStore::from_json(body);
|
||||
|
||||
let conn_id: Uuid = signals
|
||||
.get("connId")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
|
||||
let tree: ComponentTree = signals
|
||||
.get("tree")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Run the mutation
|
||||
if let Err(e) = mutation.await {
|
||||
let error_html = format!(r#"<div id="app"><p>Action error: {}</p></div>"#, e);
|
||||
return (
|
||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||
format_patch_elements(&error_html),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Rerender
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, keys, signals_json)) => {
|
||||
state.connections.update_signals(&conn_id, signals_json);
|
||||
state.subscriptions.update(conn_id, keys);
|
||||
|
||||
let mut sse_body = String::new();
|
||||
// Optionally patch signals (e.g. clear the input)
|
||||
if let Some(ps) = patch_signals {
|
||||
sse_body.push_str(&format_patch_signals(ps));
|
||||
}
|
||||
sse_body.push_str(&format_patch_elements(&html));
|
||||
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||
sse_body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let error_html = format!(r#"<div id="app"><p>Render error: {}</p></div>"#, e);
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
||||
format_patch_elements(&error_html),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /action/add_todo` — Create a new todo, then rerender.
|
||||
///
|
||||
/// Reads the `newTodo` signal from the POST body, inserts it, and sends
|
||||
/// back a `datastar-patch-signals` event to clear the input followed by
|
||||
/// a `datastar-patch-elements` event with the rerendered page.
|
||||
async fn action_add_todo(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let title = body
|
||||
.get("newTodo")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let db = state.db.clone();
|
||||
action_then_render(
|
||||
&state,
|
||||
body,
|
||||
async move {
|
||||
if !title.is_empty() {
|
||||
queries::create_todo(&db, &title).await?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
Some("{newTodo: ''}"), // Clear the input after adding
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /action/toggle_todo?id=<i64>` — Toggle a todo's completed status.
|
||||
async fn action_toggle_todo(
|
||||
State(state): State<SharedState>,
|
||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.clone();
|
||||
let id = params.id;
|
||||
action_then_render(
|
||||
&state,
|
||||
body,
|
||||
async move { queries::toggle_todo(&db, id).await },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /action/delete_todo?id=<i64>` — Delete a todo.
|
||||
async fn action_delete_todo(
|
||||
State(state): State<SharedState>,
|
||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.clone();
|
||||
let id = params.id;
|
||||
action_then_render(
|
||||
&state,
|
||||
body,
|
||||
async move { queries::delete_todo(&db, id).await },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::framework::CacheKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single todo item. Maps to the `todos` SQLite table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Todo {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read queries (go through cx.run / Query trait)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch all todos, ordered by id.
|
||||
pub struct ListTodos;
|
||||
|
||||
crate::query_all!(ListTodos {
|
||||
sql: "SELECT id, title, completed FROM todos ORDER BY id",
|
||||
params: [],
|
||||
output: Todo,
|
||||
cache: |_s: &ListTodos| CacheKey::Table { table: "todos" },
|
||||
});
|
||||
|
||||
/// Fetch a single todo by id.
|
||||
pub struct GetTodo {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
crate::query_one!(GetTodo {
|
||||
sql: "SELECT id, title, completed FROM todos WHERE id = ?",
|
||||
params: [|s: &GetTodo| s.id],
|
||||
output: Todo,
|
||||
cache: |s: &GetTodo| CacheKey::PrimaryKey { table: "todos", id: s.id },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write operations (plain sqlx, not through Query trait)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Mutations don't go through cx.run() because they're not reads and don't
|
||||
// produce subscription keys. They're called directly by action handlers,
|
||||
// which then trigger a full rerender.
|
||||
|
||||
/// Insert a new todo. Returns the new row's id.
|
||||
pub async fn create_todo(db: &sqlx::SqlitePool, title: &str) -> anyhow::Result<i64> {
|
||||
let result = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, false)")
|
||||
.bind(title)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Toggle a todo's completed status.
|
||||
pub async fn toggle_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE todos SET completed = NOT completed WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a todo by id.
|
||||
pub async fn delete_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM todos WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::app::queries::{ListTodos, Todo};
|
||||
use crate::framework::context::RenderContext;
|
||||
use crate::framework::signal::{global, mangle};
|
||||
use maud::{html, Markup};
|
||||
|
||||
/// Root page component: renders the full todo list with add form.
|
||||
///
|
||||
/// Reads signals:
|
||||
/// - `newTodo` (global, server-relevant): the text input value
|
||||
///
|
||||
/// Exercises:
|
||||
/// - `cx.run()` for data fetching
|
||||
/// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions
|
||||
/// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item)
|
||||
/// - DataStar `data-bind`, `data-on-click`, `data-attr` attributes
|
||||
/// - `@post()` actions for mutations
|
||||
pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
// Fetch all todos
|
||||
let todos = cx.run(ListTodos).await?;
|
||||
|
||||
// Global signal for the "new todo" input. This call does two things:
|
||||
// 1. Records "newTodo" as a touched signal (for demangling tracking)
|
||||
// 2. Returns a Signal struct whose .val is "$newTodo" for expressions
|
||||
let new_todo = cx.signal("newTodo", global());
|
||||
|
||||
let todo_count = todos.len();
|
||||
let done_count = todos.iter().filter(|t| t.completed).count();
|
||||
|
||||
Ok(html! {
|
||||
div.todo-app {
|
||||
h1 { "LiveVue.rs — Todo List" }
|
||||
|
||||
// Navigation link to About page (exercises tree signal + page routing)
|
||||
nav {
|
||||
span.nav-current { "Todos" }
|
||||
" | "
|
||||
button.nav-link
|
||||
data-on-click="$tree = {page: 'About'}; @post('/render')" {
|
||||
"About"
|
||||
}
|
||||
}
|
||||
|
||||
// Add todo form.
|
||||
// data-bind-newTodo binds the input to the $newTodo signal.
|
||||
// The button uses new_todo.val ("$newTodo") in a DataStar expression.
|
||||
div.add-form {
|
||||
input
|
||||
type="text"
|
||||
placeholder="What needs to be done?"
|
||||
data-bind-newTodo=""
|
||||
;
|
||||
button
|
||||
data-on-click="@post('/action/add_todo')"
|
||||
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) {
|
||||
"Add"
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
p.stats {
|
||||
(format!("{} items, {} completed", todo_count, done_count))
|
||||
}
|
||||
|
||||
// Todo items
|
||||
ul.todo-list {
|
||||
@for todo in &todos {
|
||||
(todo_item(cx, todo))
|
||||
}
|
||||
}
|
||||
|
||||
@if todos.is_empty() {
|
||||
p.empty { "No todos yet. Add one above!" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a single todo item.
|
||||
///
|
||||
/// This is a static child component — called directly from `todo_list`,
|
||||
/// not resolved through the component registry.
|
||||
///
|
||||
/// Demonstrates per-instance, client-only signal via `mangle().client_only()`.
|
||||
fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
|
||||
// Per-instance, client-only signal for edit mode.
|
||||
// Not used in v0 rendering, but demonstrates the mangled signal API
|
||||
// and ensures it's tracked as touched. Produces: "_editing__<id>"
|
||||
let _editing = cx.signal("editing", mangle(todo.id).client_only());
|
||||
|
||||
// Build class string server-side. Since we do a full rerender on every
|
||||
// state change, there's no need for data-class here — the server knows
|
||||
// whether the todo is completed.
|
||||
let title_class = if todo.completed {
|
||||
"todo-title completed"
|
||||
} else {
|
||||
"todo-title"
|
||||
};
|
||||
|
||||
html! {
|
||||
li.todo-item id=(format!("todo-{}", todo.id)) {
|
||||
// Checkbox to toggle completion.
|
||||
// `checked[expr]` is maud's syntax for conditional boolean attributes.
|
||||
input
|
||||
type="checkbox"
|
||||
checked[todo.completed]
|
||||
data-on-click=(format!("@post('/action/toggle_todo?id={}')", todo.id))
|
||||
;
|
||||
|
||||
// Title with strikethrough when completed (class set server-side)
|
||||
span class=(title_class) {
|
||||
(todo.title)
|
||||
}
|
||||
|
||||
// Delete button
|
||||
button.delete-btn
|
||||
data-on-click=(format!("@post('/action/delete_todo?id={}')", todo.id))
|
||||
{
|
||||
"×"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use crate::framework::query::CacheKey;
|
||||
use dashmap::DashMap;
|
||||
use std::collections::HashSet;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-connection state stored in the manager.
|
||||
pub struct ConnectionState {
|
||||
/// Sender half of the SSE channel. Messages written here get forwarded
|
||||
/// to the client's long-lived SSE pipe (`GET /sse`).
|
||||
pub sse_sender: mpsc::Sender<String>,
|
||||
|
||||
/// The full signal JSON from the most recent render for this connection.
|
||||
/// Phase 2 uses this to re-enter the render pipeline on data invalidation
|
||||
/// without a client request.
|
||||
pub last_signals_json: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConnectionManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concurrent map of connection_id → ConnectionState.
|
||||
///
|
||||
/// Accessed from:
|
||||
/// - `GET /sse` handler (insert)
|
||||
/// - `POST /render` handler (read sender, update signals)
|
||||
/// - SSE drop cleanup (remove)
|
||||
/// - Phase 2: invalidation handler (read sender + signals)
|
||||
pub struct ConnectionManager {
|
||||
connections: DashMap<Uuid, ConnectionState>,
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new connection with its SSE sender.
|
||||
pub fn insert(&self, id: Uuid, sender: mpsc::Sender<String>) {
|
||||
self.connections.insert(
|
||||
id,
|
||||
ConnectionState {
|
||||
sse_sender: sender,
|
||||
last_signals_json: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the SSE sender for a connection (for sending server-initiated events).
|
||||
pub fn get_sender(&self, id: &Uuid) -> Option<mpsc::Sender<String>> {
|
||||
self.connections.get(id).map(|c| c.sse_sender.clone())
|
||||
}
|
||||
|
||||
/// Update the stored signals for a connection after a render.
|
||||
pub fn update_signals(&self, id: &Uuid, signals_json: String) {
|
||||
if let Some(mut conn) = self.connections.get_mut(id) {
|
||||
conn.last_signals_json = Some(signals_json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a connection (called on SSE pipe drop).
|
||||
pub fn remove(&self, id: &Uuid) -> Option<(Uuid, ConnectionState)> {
|
||||
self.connections.remove(id)
|
||||
}
|
||||
|
||||
/// Check if a connection exists.
|
||||
pub fn contains(&self, id: &Uuid) -> bool {
|
||||
self.connections.contains_key(id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubscriptionRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bidirectional map: CacheKey ↔ ConnectionId.
|
||||
///
|
||||
/// After each render, the connection's key associations are replaced with
|
||||
/// the new set from `cx.subscription_keys`. On connection drop, all entries
|
||||
/// for that connection are removed.
|
||||
///
|
||||
/// Phase 2: on data invalidation event, look up `key_to_conns` to find
|
||||
/// which connections need rerender.
|
||||
pub struct SubscriptionRegistry {
|
||||
key_to_conns: DashMap<CacheKey, HashSet<Uuid>>,
|
||||
conn_to_keys: DashMap<Uuid, HashSet<CacheKey>>,
|
||||
}
|
||||
|
||||
impl SubscriptionRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
key_to_conns: DashMap::new(),
|
||||
conn_to_keys: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a connection's subscription keys after a render.
|
||||
///
|
||||
/// Removes old associations and inserts new ones.
|
||||
pub fn update(&self, conn_id: Uuid, new_keys: Vec<CacheKey>) {
|
||||
// Remove old associations
|
||||
if let Some((_, old_keys)) = self.conn_to_keys.remove(&conn_id) {
|
||||
for key in &old_keys {
|
||||
if let Some(mut conns) = self.key_to_conns.get_mut(key) {
|
||||
conns.remove(&conn_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new associations
|
||||
let key_set: HashSet<CacheKey> = new_keys.into_iter().collect();
|
||||
for key in &key_set {
|
||||
self.key_to_conns
|
||||
.entry(key.clone())
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(conn_id);
|
||||
}
|
||||
self.conn_to_keys.insert(conn_id, key_set);
|
||||
}
|
||||
|
||||
/// Remove all entries for a connection (called on SSE pipe drop).
|
||||
pub fn remove_connection(&self, conn_id: &Uuid) {
|
||||
if let Some((_, keys)) = self.conn_to_keys.remove(conn_id) {
|
||||
for key in &keys {
|
||||
if let Some(mut conns) = self.key_to_conns.get_mut(key) {
|
||||
conns.remove(conn_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 2: look up which connections are subscribed to a given key.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_connections_for_key(&self, key: &CacheKey) -> HashSet<Uuid> {
|
||||
self.key_to_conns
|
||||
.get(key)
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use crate::framework::signal::{Signal, SignalOpts, SignalStore};
|
||||
use crate::framework::query::{CacheKey, Query};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Describes which dynamic components are mounted, parsed from the `tree`
|
||||
/// signal.
|
||||
///
|
||||
/// Static children (always rendered by their parent) do NOT appear here —
|
||||
/// only dynamic/conditional slots. In v0 we primarily use `page` to resolve
|
||||
/// the root component. The `children` map is the seam for nested dynamic
|
||||
/// components in Phase 2.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComponentTree {
|
||||
/// The root page component name, e.g. `"TodoList"`.
|
||||
pub page: String,
|
||||
/// Dynamic child slots. Key is the slot name, value describes the child.
|
||||
#[serde(default)]
|
||||
pub children: HashMap<String, ComponentNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComponentNode {
|
||||
pub component: String,
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ComponentTree {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: "TodoList".into(),
|
||||
children: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The component's window into the framework.
|
||||
///
|
||||
/// Passed as `&mut cx` to every component function. Provides access to
|
||||
/// signals, database queries, and subscription key collection.
|
||||
///
|
||||
/// Phase 2 adds: cache handle, auth/session info.
|
||||
pub struct RenderContext {
|
||||
/// This connection's UUID.
|
||||
pub connection_id: Uuid,
|
||||
|
||||
/// Signal values populated from the incoming POST body (or initial
|
||||
/// defaults on first page load).
|
||||
pub signals: SignalStore,
|
||||
|
||||
/// Parsed component tree from the `tree` signal.
|
||||
pub tree: ComponentTree,
|
||||
|
||||
/// Database pool. Cloneable (it's an Arc internally).
|
||||
pub db: sqlx::SqlitePool,
|
||||
|
||||
/// Subscription keys accumulated during this render. After render
|
||||
/// completes, these are flushed to the SubscriptionRegistry for this
|
||||
/// connection.
|
||||
pub(crate) subscription_keys: Vec<CacheKey>,
|
||||
|
||||
/// Signal names touched during this render (for demangling tracking).
|
||||
pub(crate) touched_signals: Vec<String>,
|
||||
}
|
||||
|
||||
impl RenderContext {
|
||||
pub fn new(
|
||||
connection_id: Uuid,
|
||||
signals: SignalStore,
|
||||
tree: ComponentTree,
|
||||
db: sqlx::SqlitePool,
|
||||
) -> Self {
|
||||
Self {
|
||||
connection_id,
|
||||
signals,
|
||||
tree,
|
||||
db,
|
||||
subscription_keys: Vec::new(),
|
||||
touched_signals: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Signals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Create a signal reference with the given name and scoping options.
|
||||
///
|
||||
/// Records the signal as "touched" for demangling tracking.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let open = cx.signal("open", mangle(card.id));
|
||||
/// let val: bool = open.read_or(&cx.signals, false);
|
||||
/// ```
|
||||
pub fn signal(&mut self, name: &str, opts: SignalOpts) -> Signal {
|
||||
let mangled = opts.apply(name);
|
||||
self.touched_signals.push(mangled.clone());
|
||||
Signal::new(mangled)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Execute a single query, collecting its subscription key.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let todos = cx.run(ListTodos).await?;
|
||||
/// ```
|
||||
pub async fn run<Q: Query>(&mut self, query: Q) -> anyhow::Result<Q::Output> {
|
||||
let key = query.cache_key();
|
||||
if key != CacheKey::None {
|
||||
self.subscription_keys.push(key);
|
||||
}
|
||||
query.execute(&self.db).await
|
||||
}
|
||||
|
||||
/// Execute two queries concurrently, collecting both subscription keys.
|
||||
///
|
||||
/// This avoids the `&mut self` borrow conflict by collecting keys
|
||||
/// synchronously first, then running the async executions against a
|
||||
/// cloned pool handle (pool clones are cheap — they share the inner Arc).
|
||||
///
|
||||
/// ```ignore
|
||||
/// let (board, columns) = cx.run_all(
|
||||
/// GetBoard { id: 1 },
|
||||
/// ListColumns { board_id: 1 },
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn run_all<A: Query, B: Query>(
|
||||
&mut self,
|
||||
a: A,
|
||||
b: B,
|
||||
) -> anyhow::Result<(A::Output, B::Output)> {
|
||||
// Collect subscription keys synchronously (no borrow conflict)
|
||||
let key_a = a.cache_key();
|
||||
let key_b = b.cache_key();
|
||||
if key_a != CacheKey::None {
|
||||
self.subscription_keys.push(key_a);
|
||||
}
|
||||
if key_b != CacheKey::None {
|
||||
self.subscription_keys.push(key_b);
|
||||
}
|
||||
|
||||
// Execute concurrently against a cloned pool handle
|
||||
let db = self.db.clone();
|
||||
let (ra, rb) = tokio::try_join!(a.execute(&db), b.execute(&db))?;
|
||||
Ok((ra, rb))
|
||||
}
|
||||
|
||||
/// Three-query variant of `run_all`.
|
||||
pub async fn run_all3<A: Query, B: Query, C: Query>(
|
||||
&mut self,
|
||||
a: A,
|
||||
b: B,
|
||||
c: C,
|
||||
) -> anyhow::Result<(A::Output, B::Output, C::Output)> {
|
||||
let key_a = a.cache_key();
|
||||
let key_b = b.cache_key();
|
||||
let key_c = c.cache_key();
|
||||
if key_a != CacheKey::None {
|
||||
self.subscription_keys.push(key_a);
|
||||
}
|
||||
if key_b != CacheKey::None {
|
||||
self.subscription_keys.push(key_b);
|
||||
}
|
||||
if key_c != CacheKey::None {
|
||||
self.subscription_keys.push(key_c);
|
||||
}
|
||||
|
||||
let db = self.db.clone();
|
||||
let (ra, rb, rc) = tokio::try_join!(a.execute(&db), b.execute(&db), c.execute(&db))?;
|
||||
Ok((ra, rb, rc))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Post-render access
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Take the subscription keys collected during this render.
|
||||
/// Called by the handler after rendering to flush them to the registry.
|
||||
pub fn take_subscription_keys(&mut self) -> Vec<CacheKey> {
|
||||
std::mem::take(&mut self.subscription_keys)
|
||||
}
|
||||
|
||||
/// Take the touched signal names.
|
||||
pub fn take_touched_signals(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.touched_signals)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod signal;
|
||||
pub mod query;
|
||||
pub mod context;
|
||||
pub mod connection;
|
||||
pub mod sse;
|
||||
|
||||
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
|
||||
pub use query::{CacheKey, Query};
|
||||
pub use context::{RenderContext, ComponentTree, ComponentNode};
|
||||
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry};
|
||||
pub use sse::{format_patch_elements, format_patch_signals};
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CacheKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Identifies what data a query depends on, for subscription tracking.
|
||||
///
|
||||
/// In v0 these are collected but not acted on. Phase 2 uses them to map
|
||||
/// data changes → affected connections → rerender.
|
||||
///
|
||||
/// Phase 2 adds: `Derived { key: String, value: i64 }` for composite keys
|
||||
/// like `"posts:user_id:42"`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum CacheKey {
|
||||
/// Query has no cacheable identity (rare — side-effect-only reads).
|
||||
None,
|
||||
/// Single row by primary key: `("todos", 42)`.
|
||||
PrimaryKey { table: &'static str, id: i64 },
|
||||
/// future, something like this
|
||||
//ColumnValue { table: &'static str, column: &'static str, val: Box<dyn Any> },
|
||||
/// Entire table: `("todos")`. Used for list queries.
|
||||
Table { table: &'static str },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The central data-access abstraction.
|
||||
///
|
||||
/// Every read goes through `cx.run(SomeQuery { ... })`, which:
|
||||
/// 1. Collects the query's `cache_key` for subscription tracking.
|
||||
/// 2. Executes the query against the database.
|
||||
///
|
||||
/// Phase 2 inserts a cache check between steps 1 and 2.
|
||||
///
|
||||
/// The trait uses `Pin<Box<dyn Future>>` instead of `async fn` to guarantee
|
||||
/// `Send` bounds needed for `tokio::try_join!` in `cx.run_all()`. The
|
||||
/// `query_one!` / `query_all!` macros hide this behind a clean declaration.
|
||||
pub trait Query: Send + Sync {
|
||||
type Output: Send;
|
||||
|
||||
fn cache_key(&self) -> CacheKey;
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
db: &'a sqlx::SqlitePool,
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Self::Output>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// query_one! — fetch exactly one row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a `Query` impl that fetches a single row.
|
||||
///
|
||||
/// Both `params` and `cache` take closures receiving `&StructName`, so `self`
|
||||
/// is always available without hygiene issues.
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct GetTodo { pub id: i64 }
|
||||
///
|
||||
/// query_one!(GetTodo {
|
||||
/// sql: "SELECT id, title, completed FROM todos WHERE id = ?",
|
||||
/// params: [|s: &GetTodo| s.id],
|
||||
/// output: Todo,
|
||||
/// cache: |s: &GetTodo| CacheKey::PrimaryKey { table: "todos", id: s.id },
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// The `output` type must implement `sqlx::FromRow<'_, SqliteRow>`.
|
||||
#[macro_export]
|
||||
macro_rules! query_one {
|
||||
($name:ident {
|
||||
sql: $sql:expr,
|
||||
params: [$($param:expr),* $(,)?],
|
||||
output: $output:ty,
|
||||
cache: $cache:expr $(,)?
|
||||
}) => {
|
||||
impl $crate::framework::Query for $name {
|
||||
type Output = $output;
|
||||
|
||||
fn cache_key(&self) -> $crate::framework::CacheKey {
|
||||
($cache)(self)
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
db: &'a sqlx::SqlitePool,
|
||||
) -> ::std::pin::Pin<
|
||||
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
|
||||
> {
|
||||
let zelf = self;
|
||||
Box::pin(async move {
|
||||
let row = sqlx::query_as::<_, $output>($sql)
|
||||
$(.bind(($param)(zelf)))*
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(row)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// query_all! — fetch zero or more rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a `Query` impl that fetches multiple rows.
|
||||
///
|
||||
/// Both `params` and `cache` take closures receiving `&StructName`, so the
|
||||
/// style is consistent with `query_one!` even when `s` goes unused.
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct ListTodos;
|
||||
///
|
||||
/// query_all!(ListTodos {
|
||||
/// sql: "SELECT id, title, completed FROM todos ORDER BY id",
|
||||
/// params: [],
|
||||
/// output: Todo,
|
||||
/// cache: |_s: &ListTodos| CacheKey::Table { table: "todos" },
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// `Output` is `Vec<output_type>`.
|
||||
#[macro_export]
|
||||
macro_rules! query_all {
|
||||
($name:ident {
|
||||
sql: $sql:expr,
|
||||
params: [$($param:expr),* $(,)?],
|
||||
output: $output:ty,
|
||||
cache: $cache:expr $(,)?
|
||||
}) => {
|
||||
impl $crate::framework::Query for $name {
|
||||
type Output = Vec<$output>;
|
||||
|
||||
fn cache_key(&self) -> $crate::framework::CacheKey {
|
||||
($cache)(self)
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
db: &'a sqlx::SqlitePool,
|
||||
) -> ::std::pin::Pin<
|
||||
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
|
||||
> {
|
||||
let zelf = self;
|
||||
Box::pin(async move {
|
||||
let rows = sqlx::query_as::<_, $output>($sql)
|
||||
$(.bind(($param)(zelf)))*
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal scoping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Controls how a signal name is constructed.
|
||||
///
|
||||
/// Every signal must choose a scope (per-instance via `mangle(key)` or
|
||||
/// page-global via `global()`) and a visibility (server-relevant or
|
||||
/// client-only via `.client_only()`). There is no default — forcing the
|
||||
/// developer to think about "is this mine or the page's?" on every signal.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalOpts {
|
||||
pub scope: SignalScope,
|
||||
pub visibility: SignalVisibility,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SignalScope {
|
||||
/// Per-instance signal. The key is appended as `__<key>`.
|
||||
Mangled(String),
|
||||
/// Page-global signal. No suffix.
|
||||
Global,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SignalVisibility {
|
||||
/// Sent to server on every @post (no prefix).
|
||||
ServerRelevant,
|
||||
/// Never sent to server (`_` prefix).
|
||||
ClientOnly,
|
||||
}
|
||||
|
||||
/// Per-instance, server-relevant signal (most common).
|
||||
///
|
||||
/// ```ignore
|
||||
/// cx.signal("open", mangle(card.id)) // → "open__42"
|
||||
/// ```
|
||||
pub fn mangle(key: impl Display) -> SignalOpts {
|
||||
SignalOpts {
|
||||
scope: SignalScope::Mangled(key.to_string()),
|
||||
visibility: SignalVisibility::ServerRelevant,
|
||||
}
|
||||
}
|
||||
|
||||
/// Page-global, client-only signal.
|
||||
///
|
||||
/// ```ignore
|
||||
/// cx.signal("draggingCardId", client_only()) // → "_draggingCardId"
|
||||
/// ```
|
||||
pub fn client_only() -> SignalOpts {
|
||||
SignalOpts {
|
||||
scope: SignalScope::Global,
|
||||
visibility: SignalVisibility::ClientOnly,
|
||||
}
|
||||
}
|
||||
|
||||
/// Page-global, server-relevant signal.
|
||||
///
|
||||
/// ```ignore
|
||||
/// cx.signal("activeTab", global()) // → "activeTab"
|
||||
/// ```
|
||||
pub fn global() -> SignalOpts {
|
||||
SignalOpts {
|
||||
scope: SignalScope::Global,
|
||||
visibility: SignalVisibility::ServerRelevant,
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalOpts {
|
||||
/// Chain `.client_only()` to make any signal client-only.
|
||||
///
|
||||
/// ```ignore
|
||||
/// cx.signal("hovered", mangle(card.id).client_only()) // → "_hovered__42"
|
||||
/// ```
|
||||
pub fn client_only(mut self) -> Self {
|
||||
self.visibility = SignalVisibility::ClientOnly;
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute the final signal name from a base name + these options.
|
||||
pub fn apply(&self, base: &str) -> String {
|
||||
let prefix = match self.visibility {
|
||||
SignalVisibility::ClientOnly => "_",
|
||||
SignalVisibility::ServerRelevant => "",
|
||||
};
|
||||
let suffix = match &self.scope {
|
||||
SignalScope::Mangled(key) => format!("__{}", key),
|
||||
SignalScope::Global => String::new(),
|
||||
};
|
||||
format!("{}{}{}", prefix, base, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A resolved signal reference. Produced by `cx.signal()`.
|
||||
///
|
||||
/// Use `name` in `data-bind` attributes and `val` in expressions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Signal {
|
||||
/// Raw name, e.g. `"open__42"` or `"_draggingCardId"`.
|
||||
/// Use for `data-bind-{name}`.
|
||||
pub name: String,
|
||||
/// `$`-prefixed name for DataStar expressions, e.g. `"$open__42"`.
|
||||
pub val: String,
|
||||
}
|
||||
|
||||
impl Signal {
|
||||
pub fn new(name: String) -> Self {
|
||||
let val = format!("${}", name);
|
||||
Self { name, val }
|
||||
}
|
||||
|
||||
/// Produce a key-value entry for a `data-signals` attribute.
|
||||
///
|
||||
/// Returns `"name: default"`. Wrap in `data_signals(&[...])` to build
|
||||
/// the full attribute value.
|
||||
///
|
||||
/// The `default` should be a JS literal: `"false"`, `"0"`, `"'hello'"`.
|
||||
pub fn declare(&self, default: impl Display) -> String {
|
||||
format!("{}: {}", self.name, default)
|
||||
}
|
||||
|
||||
/// Produce a set expression for `data-on-*` attributes.
|
||||
///
|
||||
/// Returns `"$name = value"`.
|
||||
pub fn set(&self, value: impl Display) -> String {
|
||||
format!("{} = {}", self.val, value)
|
||||
}
|
||||
|
||||
/// Read this signal's current value from the store, or return `default`.
|
||||
pub fn read_or<T: DeserializeOwned>(&self, store: &SignalStore, default: T) -> T {
|
||||
store
|
||||
.get(&self.name)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SignalStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Holds the signal values received from the client in a POST body.
|
||||
///
|
||||
/// Keyed by the full (mangled) signal name. Values are JSON.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SignalStore {
|
||||
values: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SignalStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Build a store from a JSON object (the POST body from DataStar).
|
||||
pub fn from_json(json: serde_json::Value) -> Self {
|
||||
let values = match json {
|
||||
serde_json::Value::Object(map) => {
|
||||
map.into_iter().collect()
|
||||
}
|
||||
_ => HashMap::new(),
|
||||
};
|
||||
Self { values }
|
||||
}
|
||||
|
||||
/// Build a store from an initial set of key-value pairs.
|
||||
pub fn from_pairs(pairs: Vec<(String, serde_json::Value)>) -> Self {
|
||||
Self {
|
||||
values: pairs.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
|
||||
self.values.get(key)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: String, value: serde_json::Value) {
|
||||
self.values.insert(key, value);
|
||||
}
|
||||
|
||||
/// Serialize all server-relevant signals (no `_` prefix) to a JSON string.
|
||||
/// Used for `last_signals_json` storage.
|
||||
pub fn to_json_string(&self) -> String {
|
||||
let obj: serde_json::Map<String, serde_json::Value> = self
|
||||
.values
|
||||
.iter()
|
||||
.filter(|(k, _)| !k.starts_with('_'))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
serde_json::Value::Object(obj).to_string()
|
||||
}
|
||||
|
||||
/// Format all signals as a JS object literal for `data-signals` attribute.
|
||||
/// Uses JSON format which is valid JS.
|
||||
pub fn to_data_signals_attr(&self) -> String {
|
||||
self.to_json_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a `data-signals` attribute value from a list of `signal.declare()`
|
||||
/// entries.
|
||||
///
|
||||
/// ```ignore
|
||||
/// data_signals(&[open.declare("false"), count.declare("0")])
|
||||
/// // → "{open__42: false, count__42: 0}"
|
||||
/// ```
|
||||
pub fn data_signals(entries: &[String]) -> String {
|
||||
format!("{{{}}}", entries.join(", "))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demangling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Demangle a signal name back to `(base_name, Option<instance_key>)`.
|
||||
///
|
||||
/// `"open__42"` → `("open", Some("42"))`
|
||||
/// `"_hovered__42"` → `("hovered", Some("42"))` (prefix stripped)
|
||||
/// `"activeTab"` → `("activeTab", None)`
|
||||
pub fn demangle(mangled: &str) -> (&str, Option<&str>) {
|
||||
// Strip client-only prefix for demangling purposes
|
||||
let stripped = mangled.strip_prefix('_').unwrap_or(mangled);
|
||||
match stripped.split_once("__") {
|
||||
Some((base, key)) => (base, Some(key)),
|
||||
None => (stripped, None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mangle_server_relevant() {
|
||||
let opts = mangle(42);
|
||||
assert_eq!(opts.apply("open"), "open__42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mangle_client_only() {
|
||||
let opts = mangle(42).client_only();
|
||||
assert_eq!(opts.apply("hovered"), "_hovered__42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_client_only() {
|
||||
let opts = client_only();
|
||||
assert_eq!(opts.apply("draggingCardId"), "_draggingCardId");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_server_relevant() {
|
||||
let opts = global();
|
||||
assert_eq!(opts.apply("activeTab"), "activeTab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_demangle() {
|
||||
assert_eq!(demangle("open__42"), ("open", Some("42")));
|
||||
assert_eq!(demangle("_hovered__42"), ("hovered", Some("42")));
|
||||
assert_eq!(demangle("activeTab"), ("activeTab", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_declare() {
|
||||
let sig = Signal::new("open__42".into());
|
||||
assert_eq!(sig.declare("false"), "open__42: false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_set() {
|
||||
let sig = Signal::new("open__42".into());
|
||||
assert_eq!(sig.set("true"), "$open__42 = true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_signals_helper() {
|
||||
let result = data_signals(&[
|
||||
"open__42: false".into(),
|
||||
"count: 0".into(),
|
||||
]);
|
||||
assert_eq!(result, "{open__42: false, count: 0}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/// SSE event formatting for DataStar v1.
|
||||
///
|
||||
/// DataStar v1 SSE event names (verified against https://data-star.dev/reference/sse_events):
|
||||
///
|
||||
/// - `datastar-patch-elements` — morph HTML into the DOM by element ID
|
||||
/// Each line of HTML is prefixed with `data: elements `
|
||||
///
|
||||
/// - `datastar-patch-signals` — update client-side signals
|
||||
/// Signals are a JS object literal: `data: signals {key: value}`
|
||||
///
|
||||
/// SSE events are terminated by a blank line (`\n\n`).
|
||||
|
||||
/// Format a `datastar-patch-elements` SSE event.
|
||||
///
|
||||
/// The `html` should contain a top-level element with an `id` attribute
|
||||
/// (e.g. `<div id="app">...</div>`). DataStar will morph it into the
|
||||
/// existing element with that ID.
|
||||
///
|
||||
/// Multi-line HTML is split and each line gets the `data: elements ` prefix,
|
||||
/// per the SSE protocol (multiple `data:` lines are concatenated with `\n`
|
||||
/// by the SSE parser; DataStar then strips the `elements ` prefix from each).
|
||||
pub fn format_patch_elements(html: &str) -> String {
|
||||
let mut event = String::from("event: datastar-patch-elements\n");
|
||||
for line in html.lines() {
|
||||
event.push_str("data: elements ");
|
||||
event.push_str(line);
|
||||
event.push('\n');
|
||||
}
|
||||
event.push('\n'); // blank line terminates SSE event
|
||||
event
|
||||
}
|
||||
|
||||
/// Format a `datastar-patch-signals` SSE event.
|
||||
///
|
||||
/// `signals_js` should be a JS object literal string, e.g. `{foo: '', bar: 0}`.
|
||||
/// Note: this is JS syntax (unquoted keys OK), not strict JSON.
|
||||
pub fn format_patch_signals(signals_js: &str) -> String {
|
||||
format!(
|
||||
"event: datastar-patch-signals\ndata: signals {}\n\n",
|
||||
signals_js
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_patch_elements_single_line() {
|
||||
let result = format_patch_elements(r#"<div id="app">hello</div>"#);
|
||||
assert_eq!(
|
||||
result,
|
||||
"event: datastar-patch-elements\ndata: elements <div id=\"app\">hello</div>\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_patch_elements_multi_line() {
|
||||
let html = "<div id=\"app\">\n <h1>Hello</h1>\n</div>";
|
||||
let result = format_patch_elements(html);
|
||||
let expected = concat!(
|
||||
"event: datastar-patch-elements\n",
|
||||
"data: elements <div id=\"app\">\n",
|
||||
"data: elements <h1>Hello</h1>\n",
|
||||
"data: elements </div>\n",
|
||||
"\n",
|
||||
);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_patch_signals() {
|
||||
let result = format_patch_signals("{newTodo: ''}");
|
||||
assert_eq!(
|
||||
result,
|
||||
"event: datastar-patch-signals\ndata: signals {newTodo: ''}\n\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
mod framework;
|
||||
mod app;
|
||||
|
||||
use app::AppState;
|
||||
use framework::connection::{ConnectionManager, SubscriptionRegistry};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// -----------------------------------------------------------------------
|
||||
// Database setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// SQLite with create-if-missing. In-memory `:memory:` also works for dev.
|
||||
let db = sqlx::SqlitePool::connect("sqlite:livevue.db?mode=rwc").await?;
|
||||
|
||||
// Run migrations inline (no migration files needed for v0).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
completed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Seed a few todos if the table is empty (nice for first run)
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM todos")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
if count.0 == 0 {
|
||||
for title in &["Learn Rust", "Build LiveVue.rs", "Ship v0"] {
|
||||
sqlx::query("INSERT INTO todos (title, completed) VALUES (?, false)")
|
||||
.bind(title)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Application state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
connections: ConnectionManager::new(),
|
||||
subscriptions: SubscriptionRegistry::new(),
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Server
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let app = app::router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
println!("LiveVue.rs v0.1 listening on http://localhost:3000");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user