Drops porting-history narratives (dodrenett), superseded-behavior explanations, and restatements of what the next line does. Constraint notes (fail-closed policies, CAS semantics, cascade behavior, id uniqueness) stay, just shorter. No code changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
20b24f08c2
commit
6932816c42
+26
-52
@@ -1,16 +1,8 @@
|
||||
//! Read-model storage for submitted answers - the KV-backed projection
|
||||
//! of the durable event log now underneath it (`events::store`,
|
||||
//! `aggregates`) for every bucket that has a declared state graph in
|
||||
//! `AppState.aggregates` (loaded from `aggregates.yaml` - see
|
||||
//! `content::load_aggregates_from_gitea`). An `Alternative.record_as`/
|
||||
//! `ResourceSpec`'s bucket is still just a bucket name as far as
|
||||
//! `resource::get_resource` is concerned - this file is where that name
|
||||
//! additionally gets checked against the real, content-declared
|
||||
//! transition table, for the buckets that have one. A bucket with no
|
||||
//! entry in `aggregates` still works exactly as before (direct KV
|
||||
//! mutate-in-place, no event log, no CAS) - this is deliberately not a
|
||||
//! hard cutover, so content isn't forced to declare a state graph
|
||||
//! before it needs one.
|
||||
//! Read-model storage for submitted answers: the KV projection of the
|
||||
//! event log (`events::store`, `aggregates`) for every bucket with a
|
||||
//! state graph in `aggregates.yaml`. A bucket with no graph still
|
||||
//! works - direct KV mutate, no event log, no CAS - so content isn't
|
||||
//! forced to declare one before it needs it.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -95,11 +87,9 @@ pub async fn store_answer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One selected row's pending transition - `feature_name` is carried
|
||||
/// per-item (not once per call) so a batch can span more than one
|
||||
/// resource feature on the same alternative, even though today's
|
||||
/// content never actually declares more than one transitionable
|
||||
/// resource per alternative.
|
||||
/// One selected row's pending transition. `feature_name` is per-item
|
||||
/// so a batch can span more than one resource feature on the same
|
||||
/// alternative.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TransitionItem {
|
||||
pub feature_name: String,
|
||||
@@ -107,15 +97,10 @@ pub struct TransitionItem {
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Moves every selected row to its chosen target state in one call -
|
||||
/// the batched counterpart to what used to be a `transition_answer`
|
||||
/// fired separately per row, one "Confirm" button each. Now there's
|
||||
/// one shared button per alternative (see `AlternativeCard`), and a
|
||||
/// click here applies whatever was selected across every row at once.
|
||||
/// Each item is independent - there's no cross-item transaction to
|
||||
/// have, only per-item CAS (see `aggregates::transition`) - so one
|
||||
/// item's failure doesn't roll back or block the others. Failures are
|
||||
/// collected and reported together; whatever succeeded stays applied.
|
||||
/// Moves every selected row to its chosen target state in one call
|
||||
/// (the shared per-alternative Confirm button). Items are independent
|
||||
/// - per-item CAS, no cross-item transaction - so one failure doesn't
|
||||
/// roll back the others; failures are collected and reported together.
|
||||
#[server]
|
||||
pub async fn transition_answers(
|
||||
question_id: String,
|
||||
@@ -233,12 +218,9 @@ async fn apply_transition(
|
||||
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
|
||||
let mut answer: Answer =
|
||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
// Matched on (from, to), not to alone - two declared transitions
|
||||
// may share a target (open -> declined and in_dialogue -> declined),
|
||||
// and only the one whose `from` is the row's actual current state
|
||||
// is valid to fire. This also subsumes the old blanket
|
||||
// "already decided" check: a row in a state no declared transition
|
||||
// starts from simply has no legal move here.
|
||||
// Matched on (from, to), not to alone: two declared transitions may
|
||||
// share a target (open -> declined, in_dialogue -> declined), and
|
||||
// only the one starting from the row's actual state may fire.
|
||||
let transition = resource
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -255,13 +237,9 @@ async fn apply_transition(
|
||||
let decision_payload_for_event =
|
||||
serde_json::json!({ "to": item.to, "item": item.item_id, "by": user.username });
|
||||
|
||||
// For any bucket with a declared state graph, the real fix for the
|
||||
// lost-update race two concurrent decisions on the same item used to
|
||||
// hit: append with CAS on the aggregate's just-replayed sequence, so
|
||||
// a second racing caller's write is rejected instead of silently
|
||||
// overwriting the first. A bucket outside `aggregates` falls back to
|
||||
// the direct KV mutate this always did - not every resource has to
|
||||
// be event-sourced to keep working.
|
||||
// CAS on the aggregate's replayed sequence guards two concurrent
|
||||
// decisions on the same item; a schemaless bucket falls back to the
|
||||
// plain KV mutate below.
|
||||
if let Some(schema) = state.aggregates.load().get(bucket) {
|
||||
transition_or_reseed(
|
||||
&state.jetstream,
|
||||
@@ -286,10 +264,8 @@ async fn apply_transition(
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
// Extend the DAG: the decision is a child node of the answer's own
|
||||
// submission hash, published the same way any other answer is -
|
||||
// question_id/alternative come from this call's own arguments and
|
||||
// the content-declared transition label, never a hardcoded value.
|
||||
// The decision extends the DAG as a child of the answer's own
|
||||
// submission hash, published like any other answer.
|
||||
let parent_hashes = vec![item.item_id.clone()];
|
||||
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let event = AnswerSubmitted {
|
||||
@@ -307,14 +283,12 @@ async fn apply_transition(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The self-service counterpart to `transition_answer`: no signed-in
|
||||
/// session, no group check - authorized instead by already holding
|
||||
/// `item_id` (a chain hash, opaque and unguessable) plus a matching
|
||||
/// `email`, both carried in the link itself (see
|
||||
/// `content::SelfTransition`). Deliberately returns the same generic
|
||||
/// error for "no such item" and "email doesn't match" - a real
|
||||
/// unsubscribe link should never let someone probe which chain hashes
|
||||
/// or emails exist.
|
||||
/// The self-service counterpart to `transition_answers`: no session,
|
||||
/// no group check - authorized by holding `item_id` (an unguessable
|
||||
/// chain hash) plus a matching `email`, both carried in the link
|
||||
/// itself. Deliberately returns the same generic error for "no such
|
||||
/// item" and "email doesn't match", so an unsubscribe link can't be
|
||||
/// used to probe which hashes or emails exist.
|
||||
#[server]
|
||||
pub async fn self_transition_answer(
|
||||
question_id: String,
|
||||
|
||||
Reference in New Issue
Block a user