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
-148
View File
@@ -1,148 +0,0 @@
//! One-time migration: for each of the three renamed buckets
//! (`portal_applicants` -> `applicants`, `portal_subscribers` ->
//! `subscribers`, `portal_project_inquiries` -> `projects`), seeds the
//! new event log with a creation event (plus a decision event for any
//! record already decided) and re-puts the record into the new-named
//! KV bucket - then immediately replays what it just wrote and checks
//! it reproduces the original record's state exactly, per the
//! event-sourcing plan's verification step.
//!
//! Defaults to a dry run (lists what it *would* do, writes nothing) -
//! pass `--apply` to actually write. The old-named buckets are never
//! touched or deleted by this tool; remove them by hand once every
//! record's replay has been confirmed to match.
#![cfg(feature = "ssr")]
use portal::aggregates::{self, applicant, project, subscriber, AggregateKind};
use portal::answers::{Answer, OPEN_STATE};
use portal::events::store as event_store;
const MIGRATIONS: &[(&str, &str, &str)] = &[
("portal_applicants", "applicants", "applicant"),
("portal_subscribers", "subscribers", "subscriber"),
("portal_project_inquiries", "projects", "project"),
];
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let apply = std::env::args().any(|a| a == "--apply");
let nats_url =
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());
let parsed = url::Url::parse(&nats_url)?;
let mut opts = async_nats::ConnectOptions::new();
if !parsed.username().is_empty() {
opts = opts.user_and_password(
parsed.username().to_string(),
parsed.password().unwrap_or_default().to_string(),
);
}
let nats = opts.connect(&nats_url).await?;
let js = async_nats::jetstream::new(nats);
event_store::ensure_stream(&js).await?;
println!("mode: {}\n", if apply { "APPLY (writing)" } else { "DRY RUN (no writes)" });
for (old_bucket, new_bucket, agg_type) in MIGRATIONS {
println!("=== {old_bucket} -> {new_bucket} ({agg_type}) ===");
let store = match js.get_key_value(*old_bucket).await {
Ok(s) => s,
Err(e) => {
println!(" skip: bucket unavailable: {e}");
continue;
}
};
use futures::TryStreamExt;
let keys: Vec<String> = store.keys().await?.try_collect().await?;
if keys.is_empty() {
println!(" (empty)");
}
for key in keys {
let Some(bytes) = store.get(&key).await? else { continue };
let answer: Answer = serde_json::from_slice(&bytes)?;
println!(" {key}: state={:?} submitted_ms={}", answer.state, answer.submitted_ms);
if apply {
if let Err(e) = seed_and_verify(&js, agg_type, &key, &answer, new_bucket).await {
println!(" ! {e}");
}
}
}
}
if !apply {
println!("\nDry run only - no writes made. Re-run with --apply to actually backfill.");
}
Ok(())
}
async fn seed_and_verify(
js: &async_nats::jetstream::Context,
agg_type: &str,
id: &str,
answer: &Answer,
new_bucket: &str,
) -> anyhow::Result<()> {
match agg_type {
"applicant" => seed::<applicant::State>(js, id, answer).await?,
"subscriber" => seed::<subscriber::State>(js, id, answer).await?,
"project" => seed::<project::State>(js, id, answer).await?,
other => anyhow::bail!("unknown aggregate type {other}"),
}
let new_store = match js.get_key_value(new_bucket).await {
Ok(s) => s,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: new_bucket.to_string(),
..Default::default()
})
.await?
}
};
new_store.put(id, serde_json::to_vec(answer)?.into()).await?;
Ok(())
}
/// Appends the initial event, then - if the original record was
/// already decided - a second event carrying the real decision, before
/// replaying and checking the result matches. Two events for a decided
/// record, not one: a single "imported" event would show a decided
/// record as forever open on replay.
async fn seed<K: AggregateKind>(
js: &async_nats::jetstream::Context,
id: &str,
answer: &Answer,
) -> anyhow::Result<()> {
aggregates::create::<K>(js, id, answer.responses.clone(), answer.submitted_ms)
.await
.map_err(|e| anyhow::anyhow!("seeding initial event: {e}"))?;
if answer.state != OPEN_STATE {
match K::from_event_type(&answer.state) {
Some(target) => {
let payload = serde_json::json!({
"to": answer.state,
"item": id,
"by": answer.decided_by,
});
let occurred = answer.decided_ms.unwrap_or(answer.submitted_ms);
aggregates::transition::<K>(js, id, target, payload, occurred)
.await
.map_err(|e| anyhow::anyhow!("seeding decision event: {e}"))?;
}
None => println!(" ! unrecognized decided state {:?}, only seeded initial event", answer.state),
}
}
let events = event_store::load_events(js, K::AGGREGATE_TYPE, id).await?;
match aggregates::replay::<K>(id, &events) {
Some(agg) if agg.state.event_type() == answer.state => {
println!(" OK: replay matches original state {:?}", answer.state);
}
Some(agg) => {
println!(" MISMATCH: replayed {:?}, original was {:?}", agg.state, answer.state);
}
None => println!(" MISMATCH: no aggregate replayed at all"),
}
Ok(())
}
+44 -13
View File
@@ -1,13 +1,15 @@
//! Headless schema-check binary - loads content (from a Gitea repo URL
//! or a local directory) and validates it exactly the way
//! `content::watch_for_reload`/`main.rs`'s boot path do, with no NATS,
//! OIDC, web server, or JetStream connection involved. Built once by
//! portal's own deploy workflow and downloaded as a release artifact by
//! `questions`' own CI, rather than compiled there - keeps that repo's
//! CI coupling to "run a static binary," not "build a Rust workspace."
//! Headless schema-check binary - loads content and `aggregates.yaml`
//! (from a Gitea repo URL or a local directory) and validates them
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
//! do, with no NATS, OIDC, web server, or JetStream connection
//! involved. Built once by portal's own deploy workflow and run
//! directly by `questions`' own CI (same bare-metal runner/host,
//! published to a stable path - no artifact download needed), rather
//! than compiled there - keeps that repo's CI coupling to "run a
//! static binary," not "build a Rust workspace."
#![cfg(feature = "ssr")]
use portal::content;
use portal::{aggregates, content};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -30,10 +32,12 @@ async fn main() -> anyhow::Result<()> {
}
}
let questions = if let Some(dir) = path {
load_from_dir(&dir)?
let (questions, aggregates_map) = if let Some(dir) = path {
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?)
} else if let Some(repo_url) = repo {
content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map)
} else {
eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
@@ -41,9 +45,13 @@ async fn main() -> anyhow::Result<()> {
std::process::exit(2);
};
match content::validate_questions(&questions) {
match content::validate_questions(&questions, &aggregates_map) {
Ok(()) => {
println!("OK: {} question(s) valid", questions.len());
println!(
"OK: {} question(s), {} aggregate(s) valid",
questions.len(),
aggregates_map.len()
);
Ok(())
}
Err(e) => {
@@ -53,6 +61,29 @@ async fn main() -> anyhow::Result<()> {
}
}
/// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's
/// looked for. Missing entirely is not an error here (unlike
/// `--repo` mode, where `aggregates.yaml` is required) - a local
/// checkout being linted may not have one, and every declared
/// transition still gets checked against whatever *is* found; an
/// empty map just means nothing is checked.
fn load_aggregates_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
let aggregates_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("aggregates.yaml");
if !aggregates_path.exists() {
return Ok(std::collections::HashMap::new());
}
let raw = std::fs::read_to_string(&aggregates_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
aggregates::parse_aggregates_yaml(&raw)
}
/// The offline counterpart to `content::load_questions_from_gitea` -
/// same "every `*.yaml` file becomes a `Question` keyed by its own
/// `id`" shape, just reading a local checkout instead of Gitea's API,