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
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user