Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s
Deploy / deploy (push) Failing after 3s
Replaces the free-string, direct-KV-mutate state model in answers.rs
with a proper event log (events/store.rs, JetStream-backed, CAS via
expected_last_subject_sequence) and three pure state machines
(aggregates/{applicant,subscriber,project}.rs). Closes a real
lost-update race in the old transition_answer (concurrent decisions on
the same item could both win, publishing contradictory events). KV
buckets become best-effort read-model projections, not the source of
truth. Content-declared transition targets are now validated at
load/reload time against the real compiled transition tables, not
accepted as arbitrary strings.
Buckets renamed to describe their content, not their relation to the
app (portal_applicants -> applicants, etc); "inquiry" folded into a
richer "project" concept.
ResourceSpec generalized beyond a single KV bucket: Kv | GiteaStarred |
GiteaOrgRepos | Url sources, with an optional jq filter (via the jaq
crate) to shape live data for the frontend. Url source is SSRF-guarded
(https-only, rejects loopback/private/link-local, real DNS resolve).
New headless question_lint binary (validates content against compiled
transition tables with no NATS/OIDC/server involved) and a one-time
backfill_events binary (dry-run by default) for migrating existing KV
data onto the new event log.
Questions get an optional `responsible` contact plus a lightweight
"report this question" action.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
213b1130bb
commit
ebf4bf91b3
+157
-20
@@ -1,15 +1,15 @@
|
||||
//! Durable, queryable storage for submitted answers - the KV-backed
|
||||
//! counterpart to the ephemeral `events::AnswerSubmitted` every
|
||||
//! submission already publishes. The runtime here has no notion of
|
||||
//! "applicant" or any other specific workflow: an `Alternative.record_as`
|
||||
//! names a NATS KV bucket (nothing more); on submit, the response gets
|
||||
//! wrapped in the same envelope (`Answer`) regardless of what content
|
||||
//! produced it. `resource::get_resource` reads it back generically, and
|
||||
//! `transition_answer` below moves an answer between states named in
|
||||
//! content (`ResourceSpec.transitions`), never states hardcoded here.
|
||||
//! Whatever workflow this ends up powering - applicant review, order
|
||||
//! triage, comment moderation - is entirely a property of some
|
||||
//! `portal-content` YAML, not of this file.
|
||||
//! Read-model storage for submitted answers - the KV-backed projection
|
||||
//! of the durable event log now underneath it (`events::store`,
|
||||
//! `aggregates`) for the three buckets migrated onto that machinery
|
||||
//! this pass (`content::aggregate_type_for_bucket`). 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 a real,
|
||||
//! Rust-defined transition table, for the buckets that have one.
|
||||
//! A bucket outside that three-entry map 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 migrate
|
||||
//! before it needs to.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -71,7 +71,30 @@ pub async fn store_answer(
|
||||
decided_by: None,
|
||||
};
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
store.put(id, data.into()).await?;
|
||||
store.put(id.clone(), data.into()).await?;
|
||||
|
||||
// Also seed the event log for the three aggregate types migrated
|
||||
// onto it this pass - best-effort, same "a hiccup here shouldn't
|
||||
// fail a submission the NATS notification event has already
|
||||
// recorded" policy as the KV write above.
|
||||
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(bucket) {
|
||||
use crate::aggregates::{applicant, create, project, subscriber};
|
||||
let seed = match agg_type {
|
||||
"applicant" => create::<applicant::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
"subscriber" => create::<subscriber::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
"project" => create::<project::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(e) = seed {
|
||||
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -138,9 +161,12 @@ pub async fn transition_answer(
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
}
|
||||
|
||||
let bucket = resource
|
||||
.bucket()
|
||||
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&resource.bucket)
|
||||
.get_key_value(bucket)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
||||
let bytes = store
|
||||
@@ -155,6 +181,22 @@ pub async fn transition_answer(
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
let decision_payload_for_event =
|
||||
serde_json::json!({ "to": to, "item": item_id, "by": user.username });
|
||||
|
||||
// For the three buckets migrated onto the event log this pass, 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 that map falls back to the direct KV mutate this always
|
||||
// did - not every resource has to be migrated to keep working.
|
||||
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(bucket) {
|
||||
transition_by_aggregate(&state.jetstream, agg_type, &item_id, &to, decision_payload_for_event.clone(), decided_ms)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
}
|
||||
|
||||
answer.state = to.clone();
|
||||
answer.decided_ms = Some(decided_ms);
|
||||
answer.decided_by = Some(user.username.clone());
|
||||
@@ -169,15 +211,14 @@ pub async fn transition_answer(
|
||||
// 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.
|
||||
let decision_payload = serde_json::json!({ "to": to, "item": item_id, "by": user.username });
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id,
|
||||
alternative: transition.label,
|
||||
responses: decision_payload,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
@@ -187,6 +228,43 @@ pub async fn transition_answer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatches to the right aggregate type's `transition()` by name -
|
||||
/// the one place `answers.rs` has to know the fixed, three-entry set of
|
||||
/// migrated aggregate types (`content::aggregate_type_for_bucket`'s
|
||||
/// counterpart), since `crate::aggregates` itself stays generic over
|
||||
/// any `AggregateKind` and has no list of its own to dispatch through.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn transition_by_aggregate(
|
||||
js: &async_nats::jetstream::Context,
|
||||
aggregate_type: &str,
|
||||
item_id: &str,
|
||||
to: &str,
|
||||
payload: serde_json::Value,
|
||||
occurred_at_ms: i64,
|
||||
) -> Result<(), crate::aggregates::TransitionError> {
|
||||
use crate::aggregates::{applicant, project, subscriber, transition, AggregateKind, TransitionError};
|
||||
|
||||
macro_rules! dispatch {
|
||||
($state:ty) => {{
|
||||
let target = <$state>::from_event_type(to).ok_or_else(|| {
|
||||
TransitionError::DisallowedTransition {
|
||||
from: "?".to_string(),
|
||||
to: to.to_string(),
|
||||
}
|
||||
})?;
|
||||
transition::<$state>(js, item_id, target, payload, occurred_at_ms).await?;
|
||||
}};
|
||||
}
|
||||
|
||||
match aggregate_type {
|
||||
"applicant" => dispatch!(applicant::State),
|
||||
"subscriber" => dispatch!(subscriber::State),
|
||||
"project" => dispatch!(project::State),
|
||||
_ => return Err(TransitionError::UnknownAggregate),
|
||||
}
|
||||
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
|
||||
@@ -244,6 +322,22 @@ pub async fn self_transition_answer(
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
let decision_payload_for_event =
|
||||
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
|
||||
|
||||
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(&self_transition.bucket) {
|
||||
transition_by_aggregate(
|
||||
&state.jetstream,
|
||||
agg_type,
|
||||
&item_id,
|
||||
&self_transition.to,
|
||||
decision_payload_for_event.clone(),
|
||||
decided_ms,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
}
|
||||
|
||||
answer.state = self_transition.to.clone();
|
||||
answer.decided_ms = Some(decided_ms);
|
||||
answer.decided_by = Some(email.clone());
|
||||
@@ -254,15 +348,14 @@ pub async fn self_transition_answer(
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let decision_payload = serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id,
|
||||
alternative: self_transition.label,
|
||||
responses: decision_payload,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
@@ -271,3 +364,47 @@ pub async fn self_transition_answer(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `app.rs`'s list-rendering path only shows the nice
|
||||
/// button-per-transition UI if a resource's raw JSON value
|
||||
/// deserializes as `Vec<Answer>` - otherwise it silently falls
|
||||
/// back to a plain read-only dump with no error. Guards that shape
|
||||
/// contract directly, since nothing else would catch a drift here.
|
||||
#[test]
|
||||
fn answer_list_round_trips_through_the_shape_app_rs_expects() {
|
||||
let answers = vec![
|
||||
Answer {
|
||||
id: "abc123".to_string(),
|
||||
question_id: "/review".to_string(),
|
||||
alternative: "Applicants".to_string(),
|
||||
responses: serde_json::json!({"name": "Ada"}),
|
||||
state: OPEN_STATE.to_string(),
|
||||
submitted_ms: 1_700_000_000_000,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
question_id: "/review".to_string(),
|
||||
alternative: "Applicants".to_string(),
|
||||
responses: serde_json::json!({"name": "Grace"}),
|
||||
state: "invited".to_string(),
|
||||
submitted_ms: 1_700_000_001_000,
|
||||
decided_ms: Some(1_700_000_002_000),
|
||||
decided_by: Some("bl".to_string()),
|
||||
},
|
||||
];
|
||||
let value = serde_json::Value::Array(
|
||||
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
|
||||
);
|
||||
let round_tripped: Vec<Answer> =
|
||||
serde_json::from_value(value).expect("Answer list round-trips");
|
||||
assert_eq!(round_tripped.len(), 2);
|
||||
assert_eq!(round_tripped[1].state, "invited");
|
||||
assert_eq!(round_tripped[1].decided_by.as_deref(), Some("bl"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user