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
+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