Event-sourced applicant/subscriber/project aggregates, generalized resources
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:
Bendik Aagaard Lynghaug
2026-08-06 08:52:29 +02:00
co-authored by Claude Sonnet 5
parent 213b1130bb
commit ebf4bf91b3
16 changed files with 1811 additions and 67 deletions
+148
View File
@@ -0,0 +1,148 @@
//! 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(())
}
+76
View File
@@ -0,0 +1,76 @@
//! 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."
#![cfg(feature = "ssr")]
use portal::content;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let mut repo: Option<String> = None;
let mut branch = "main".to_string();
let mut subdir = "questions".to_string();
let mut path: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--repo" => repo = args.next(),
"--branch" => branch = args.next().unwrap_or(branch),
"--subdir" => subdir = args.next().unwrap_or(subdir),
"--path" => path = args.next(),
other => {
eprintln!("unknown argument: {other}");
std::process::exit(2);
}
}
}
let questions = if let Some(dir) = path {
load_from_dir(&dir)?
} else if let Some(repo_url) = repo {
content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?
} else {
eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
);
std::process::exit(2);
};
match content::validate_questions(&questions) {
Ok(()) => {
println!("OK: {} question(s) valid", questions.len());
Ok(())
}
Err(e) => {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
}
}
/// 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,
/// for linting a branch that hasn't been pushed yet.
fn load_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let mut out = std::collections::HashMap::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let raw = std::fs::read_to_string(&path)?;
let question: content::Question = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
out.insert(question.id.clone(), question);
}
Ok(out)
}