Refactor Query contract: uniform ergonomics for SQLite + PostgreSQL
- Add `publish_key()` default method to `Query` trait (returns `None`) - Add `mutation_exec!` macro for SQLite INSERT/UPDATE/DELETE ops; overrides `publish_key()` so the mutation carries its own invalidation key - Add `Store::exec()` which runs a mutation and auto-publishes its key — action handlers no longer need a separate `store.publish()` call - Add `PgQuery` trait + `pg_query_one!` / `pg_query_all!` macros (feature-gated on `pg_replication`) mirroring the SQLite Query API - Add `cx.run_pg(pool, query)` to `RenderContext` which executes a PgQuery and auto-subscribes its cache key — no more manual `cx.subscribe()` calls - Update todo example: replace plain mutation fns with `mutation_exec!` structs and `store.exec()` calls; remove `action_then_publish` helper - Update pg_replication example: add `ListMessages` via `pg_query_all!` and replace manual subscribe + raw query with `cx.run_pg()` https://claude.ai/code/session_01HXe8rAZPweU9j2piBo9MdG
This commit is contained in:
@@ -46,12 +46,26 @@ use livevue_rs::{
|
||||
spawn_fanout, RenderContext, Store,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch all messages, newest first.
|
||||
pub struct ListMessages;
|
||||
|
||||
livevue_rs::pg_query_all!(ListMessages {
|
||||
sql: "SELECT id, body FROM messages ORDER BY id DESC",
|
||||
params: [],
|
||||
output: Message,
|
||||
cache: |_| pg_table_key("public", "messages"),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
struct Message {
|
||||
pub struct Message {
|
||||
id: i64,
|
||||
body: String,
|
||||
}
|
||||
@@ -101,14 +115,8 @@ struct RenderState {
|
||||
}
|
||||
|
||||
async fn render_page(cx: &mut RenderContext, rs: &RenderState) -> anyhow::Result<Markup> {
|
||||
// Subscribe to PG-sourced invalidation events for the messages table.
|
||||
// The fanout task will re-render this connection whenever the WAL emitter
|
||||
// fires a `CacheKey::Channel { key: "pg:public.messages" }` event.
|
||||
cx.subscribe(pg_table_key("public", "messages"));
|
||||
|
||||
let messages: Vec<Message> = sqlx::query_as("SELECT id, body FROM messages ORDER BY id DESC")
|
||||
.fetch_all(&rs.pg)
|
||||
.await?;
|
||||
// Fetch messages and auto-subscribe to the WAL invalidation key.
|
||||
let messages = cx.run_pg(&rs.pg, ListMessages).await?;
|
||||
|
||||
let new_msg = cx.signal("newMsg", livevue_rs::global());
|
||||
let _conn_id_signal = cx.signal("connId", livevue_rs::global());
|
||||
|
||||
+30
-52
@@ -1,5 +1,5 @@
|
||||
use livevue_rs::{
|
||||
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
||||
ComponentTree, RenderContext, SharedState, SignalStore,
|
||||
ingest_signals, sse_handler,
|
||||
};
|
||||
|
||||
@@ -155,50 +155,30 @@ struct ActionIdParams {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Ingest signals, run a mutation, then publish an invalidation event so the
|
||||
/// fanout task handles the rerender. Returns 202 Accepted.
|
||||
async fn action_then_publish(
|
||||
state: &AppState,
|
||||
body: serde_json::Value,
|
||||
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
||||
invalidation_key: CacheKey,
|
||||
) -> StatusCode {
|
||||
let signals = ingest_signals(state, extract_conn_and_tree(&SignalStore::from_json(body.clone())).0, body);
|
||||
let _ = signals; // signals are stored; mutation drives the rerender
|
||||
|
||||
if let Err(e) = mutation.await {
|
||||
eprintln!("Action mutation error: {}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
state.store.publish(invalidation_key);
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
async fn action_add_todo(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
||||
ingest_signals(&state, conn_id, body.clone());
|
||||
|
||||
let title = body
|
||||
.get("newTodo")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let db = state.store.db.clone();
|
||||
|
||||
action_then_publish(
|
||||
&state,
|
||||
body,
|
||||
async move {
|
||||
if !title.is_empty() {
|
||||
crate::queries::create_todo(&db, &title).await?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
CacheKey::Table { table: "todos" },
|
||||
)
|
||||
.await
|
||||
if title.is_empty() {
|
||||
return StatusCode::ACCEPTED;
|
||||
}
|
||||
|
||||
if let Err(e) = state.store.exec(crate::queries::CreateTodo { title }).await {
|
||||
eprintln!("action_add_todo error: {e}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
async fn action_toggle_todo(
|
||||
@@ -206,16 +186,15 @@ async fn action_toggle_todo(
|
||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.store.db.clone();
|
||||
let id = params.id;
|
||||
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
||||
ingest_signals(&state, conn_id, body);
|
||||
|
||||
action_then_publish(
|
||||
&state,
|
||||
body,
|
||||
async move { crate::queries::toggle_todo(&db, id).await },
|
||||
CacheKey::Table { table: "todos" },
|
||||
)
|
||||
.await
|
||||
if let Err(e) = state.store.exec(crate::queries::ToggleTodo { id: params.id }).await {
|
||||
eprintln!("action_toggle_todo error: {e}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
async fn action_delete_todo(
|
||||
@@ -223,14 +202,13 @@ async fn action_delete_todo(
|
||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.store.db.clone();
|
||||
let id = params.id;
|
||||
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
||||
ingest_signals(&state, conn_id, body);
|
||||
|
||||
action_then_publish(
|
||||
&state,
|
||||
body,
|
||||
async move { crate::queries::delete_todo(&db, id).await },
|
||||
CacheKey::Table { table: "todos" },
|
||||
)
|
||||
.await
|
||||
if let Err(e) = state.store.exec(crate::queries::DeleteTodo { id: params.id }).await {
|
||||
eprintln!("action_delete_todo error: {e}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
+29
-23
@@ -40,36 +40,42 @@ livevue_rs::query_one!(GetTodo {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write operations (plain sqlx, not through Query trait)
|
||||
// Write operations (mutation structs via mutation_exec!)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 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.
|
||||
// Each mutation struct implements Query with publish_key() set to the cache
|
||||
// key it invalidates. Use Store::exec() in action handlers — it executes the
|
||||
// mutation and publishes the key automatically.
|
||||
|
||||
/// 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())
|
||||
/// Insert a new todo.
|
||||
pub struct CreateTodo {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
livevue_rs::mutation_exec!(CreateTodo {
|
||||
sql: "INSERT INTO todos (title, completed) VALUES (?, false)",
|
||||
params: [|s: &CreateTodo| s.title.clone()],
|
||||
publish: |_| CacheKey::Table { table: "todos" },
|
||||
});
|
||||
|
||||
/// 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(())
|
||||
pub struct ToggleTodo {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
livevue_rs::mutation_exec!(ToggleTodo {
|
||||
sql: "UPDATE todos SET completed = NOT completed WHERE id = ?",
|
||||
params: [|s: &ToggleTodo| s.id],
|
||||
publish: |_| CacheKey::Table { table: "todos" },
|
||||
});
|
||||
|
||||
/// 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(())
|
||||
pub struct DeleteTodo {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
livevue_rs::mutation_exec!(DeleteTodo {
|
||||
sql: "DELETE FROM todos WHERE id = ?",
|
||||
params: [|s: &DeleteTodo| s.id],
|
||||
publish: |_| CacheKey::Table { table: "todos" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user