Make aggregate state graphs content-driven, not compiled Rust
Deploy / deploy (push) Successful in 59s

Six hand-maintained copies of the bucket->aggregate-type table
(4 per-type State enums, content::aggregate_type_for_bucket +
is_valid_transition_target, answers.rs's two dispatch matches,
backfill_events.rs's own migration table) collapse into one:
questions/aggregates.yaml, loaded and hot-swapped in AppState.aggregates
the same way AppState.questions already is. aggregates/mod.rs's
replay/create/transition now run off a runtime-loaded AggregateSchema
instead of a compile-time AggregateKind trait impl per type - the
CAS/JetStream mechanics underneath are unchanged. A new aggregate type
is now a content-only change, no portal deploy required.

backfill_events.rs is deleted outright rather than ported - its
migration job was already done and production data here is minimal
and expendable (the EVENTS stream gets purged by hand post-deploy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-11 21:43:32 +02:00
co-authored by Claude Sonnet 5
parent 1c1cbfc13b
commit 84c3fad339
13 changed files with 457 additions and 598 deletions
+31 -82
View File
@@ -1,15 +1,16 @@
//! 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.
//! `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.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
@@ -41,8 +42,10 @@ pub struct Answer {
/// there's no fixed, pre-declared list of buckets, since content can
/// name any.
#[cfg(feature = "ssr")]
#[allow(clippy::too_many_arguments)]
pub async fn store_answer(
js: &async_nats::jetstream::Context,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
bucket: &str,
id: String,
question_id: &str,
@@ -73,28 +76,12 @@ pub async fn store_answer(
let data = serde_json::to_vec(&answer)?;
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, organization, 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(|_| ()),
"organization" => create::<organization::State>(js, &id, responses.clone(), submitted_ms)
.await
.map(|_| ()),
_ => Ok(()),
};
if let Err(e) = seed {
// Also seed the event log for any bucket that has a declared state
// graph - 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(schema) = aggregates.get(bucket) {
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
}
}
@@ -187,15 +174,15 @@ pub async fn transition_answer(
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)
// 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.
if let Some(schema) = state.aggregates.load().get(bucket) {
crate::aggregates::transition(&state.jetstream, schema, &item_id, &to, decision_payload_for_event.clone(), decided_ms)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
@@ -231,44 +218,6 @@ 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, organization, 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),
"organization" => dispatch!(organization::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
@@ -329,10 +278,10 @@ pub async fn self_transition_answer(
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(
if let Some(schema) = state.aggregates.load().get(&self_transition.bucket) {
crate::aggregates::transition(
&state.jetstream,
agg_type,
schema,
&item_id,
&self_transition.to,
decision_payload_for_event.clone(),