Make aggregate state graphs content-driven, not compiled Rust
Deploy / deploy (push) Successful in 59s
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:
co-authored by
Claude Sonnet 5
parent
1c1cbfc13b
commit
84c3fad339
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user