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:
Claude
2026-03-11 22:37:33 +00:00
parent 23fab7a5ff
commit 9dcb0d6dc8
8 changed files with 331 additions and 84 deletions
+30 -52
View File
@@ -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
}