2026-08-11 21:43:32 +02:00
//! Generic pure-replay + CAS-write machinery for event-sourced
//! aggregates - the state graph itself (what states exist, what
//! event-type string represents each, what transitions are legal) is
//! loaded at runtime from `questions/aggregates.yaml`, not compiled
//! Rust. Content declares the graph; this module only knows how to
//! replay/append against whatever graph it's handed
//! (`content::load_aggregates_from_gitea`, hot-swapped in
//! `AppState.aggregates` the same way `AppState.questions` is).
//!
//! `AggregateSchema.bucket` doubles as both the KV bucket name
//! (`answers.rs`'s read-model projection) and the JetStream
//! event-subject segment (`events::store`'s `events.<bucket>.<id>`) -
//! one name, not two, since content only ever had one name for this to
//! begin with.
2026-08-06 08:52:29 +02:00
#![cfg(feature = "ssr" )]
2026-08-11 21:43:32 +02:00
use std ::collections ::HashMap ;
2026-08-06 08:52:29 +02:00
use crate ::events ::store ::{ append_event , load_events , Event };
2026-08-11 21:43:32 +02:00
/// One aggregate type's full state graph, already validated (see
/// `parse_aggregates_yaml`) - `initial` is guaranteed to be a key of
/// `event_for_state`/`state_for_event`, every `transitions` key and
/// target is guaranteed to be a declared state, and every state's
/// event-type string is guaranteed unique within this schema.
#[derive(Clone, Debug)]
pub struct AggregateSchema {
pub bucket : String ,
pub initial : String ,
pub event_for_state : HashMap < String , String > ,
pub state_for_event : HashMap < String , String > ,
pub transitions : HashMap < String , Vec < String >> ,
2026-08-25 15:36:07 +02:00
/// Names what consumes this bucket's answers when no page in the
/// content repo reads it (e.g. "n8n newsletter compose").
/// Free-text - it exists so `validate_questions`' attended-bucket
/// rule has an explicit, auditable opt-out instead of a silent
/// one, and so the next reader knows where the answers go.
pub attended_by : Option < String > ,
2026-09-23 19:45:48 +02:00
/// The mail a record's arrival in a state sends (see `mail.rs`):
/// declared on the state in aggregates.yaml, next to the event
/// that enters it, so content decides when a person hears
/// something and the mailer never has to know a site.
pub mail_for_state : HashMap < String , MailSpec > ,
2026-09-25 21:35:25 +02:00
/// What a state grants the person whose record entered it: a group
/// they become a member of. A firm approved to file is a member of
/// the approved firms from that moment, and the decision that made
/// it so is the same one the desk already pressed - nobody should
/// have to remember to invite them afterwards.
pub grants_for_state : HashMap < String , String > ,
/// The move a clock makes rather than a person. A hearing that gets
/// no answer in six weeks has to go on without one, and nothing in
/// a building is going to press that button.
pub after_for_state : HashMap < String , AfterSpec > ,
2026-09-23 19:45:48 +02:00
}
/// A mail to send when a record enters a state. `to` is `submitter`
/// (the record's `email` field, which the lint then requires the
2026-09-30 06:47:36 +02:00
/// forms to collect), one fixed address, or a field of the record
/// this one belongs to: `{notice.email}` when the form has a
/// `type: record` field called `notice`. That is how an answer to a
/// notice reaches whoever posted it. Subject and body are templates:
/// `{field}` for any field of the record, `{notice.field}` for one of
/// the record it belongs to, plus `{site}` (the instance's public
/// URL), `{chain}` (the record id), `{email}`, `{bucket}` and
/// `{state}`. The body is markdown; the mailer sends it as plain text
/// with an HTML alternative.
2026-09-23 19:45:48 +02:00
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MailSpec {
pub to : String ,
pub subject : String ,
pub body : String ,
}
impl MailSpec {
pub fn to_submitter ( & self ) -> bool {
self . to == "submitter"
}
2026-08-11 21:43:32 +02:00
}
2026-08-06 08:52:29 +02:00
2026-09-25 21:35:25 +02:00
/// A move made by a clock: `after: { days: 42, to: no_answer }` on the
/// state a record is waiting in. The wait is measured from when the
/// record entered that state, and `to` must be an edge the same schema
/// already declares - a deadline may only do what a person could have
/// done. Days, because every deadline a public body keeps is in days
/// and a shorter unit would invite this to be used as a job queue.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AfterSpec {
pub days : u32 ,
pub to : String ,
}
2026-08-11 21:43:32 +02:00
impl AggregateSchema {
pub fn has_state ( & self , state : & str ) -> bool {
self . event_for_state . contains_key ( state )
}
2026-09-23 19:45:48 +02:00
pub fn mail_for ( & self , state : & str ) -> Option <& MailSpec > {
self . mail_for_state . get ( state )
}
2026-09-25 21:35:25 +02:00
pub fn grants_for ( & self , state : & str ) -> Option <& str > {
self . grants_for_state . get ( state ). map ( String ::as_str )
}
pub fn after_for ( & self , state : & str ) -> Option <& AfterSpec > {
self . after_for_state . get ( state )
}
2026-08-11 21:43:32 +02:00
pub fn allowed ( & self , state : & str ) -> & [ String ] {
self . transitions
. get ( state )
. map ( | v | v . as_slice ())
. unwrap_or ( & [])
}
}
#[derive(Debug, serde::Deserialize)]
struct RawState {
event : String ,
2026-09-23 19:45:48 +02:00
#[serde(default)]
mail : Option < MailSpec > ,
2026-09-25 21:35:25 +02:00
#[serde(default)]
grants : Option < String > ,
#[serde(default)]
after : Option < AfterSpec > ,
2026-08-11 21:43:32 +02:00
}
#[derive(Debug, serde::Deserialize)]
struct RawAggregateSchema {
bucket : String ,
initial : String ,
states : HashMap < String , RawState > ,
#[serde(default)]
transitions : HashMap < String , Vec < String >> ,
2026-08-25 15:36:07 +02:00
#[serde(default)]
attended_by : Option < String > ,
2026-08-11 21:43:32 +02:00
}
#[derive(Debug, serde::Deserialize)]
struct AggregatesFile {
aggregates : Vec < RawAggregateSchema > ,
}
/// Parses and validates `aggregates.yaml`'s full contents. A malformed
/// entry (an `initial`/`transitions` key or target that isn't a
/// declared state, a duplicate `bucket`, or two states sharing one
/// event-type string) is rejected here with a clear message - the same
/// "bad content never silently corrupts a read/write" policy
/// `content::validate_questions` already applies to pages.
pub fn parse_aggregates_yaml ( raw : & str ) -> anyhow ::Result < HashMap < String , AggregateSchema >> {
let file : AggregatesFile =
serde_yaml ::from_str ( raw ). map_err ( | e | anyhow ::anyhow! ( "parsing aggregates.yaml: {e}" )) ? ;
let mut out = HashMap ::new ();
for raw_schema in file . aggregates {
let bucket = raw_schema . bucket . clone ();
if out . contains_key ( & bucket ) {
anyhow ::bail! ( "aggregates.yaml: bucket {bucket:?} declared more than once" );
}
if ! raw_schema . states . contains_key ( & raw_schema . initial ) {
anyhow ::bail! (
"aggregates.yaml: bucket {bucket:?}: initial state {:?} is not declared in states" ,
raw_schema . initial
);
}
let mut event_for_state = HashMap ::new ();
let mut state_for_event = HashMap ::new ();
for ( state , raw_state ) in & raw_schema . states {
if let Some ( existing ) = state_for_event . insert ( raw_state . event . clone (), state . clone ())
{
anyhow ::bail! (
"aggregates.yaml: bucket {bucket:?}: event {:?} used by both state {:?} and {:?}" ,
raw_state . event , existing , state
);
}
event_for_state . insert ( state . clone (), raw_state . event . clone ());
}
for ( from , targets ) in & raw_schema . transitions {
if ! raw_schema . states . contains_key ( from ) {
anyhow ::bail! (
"aggregates.yaml: bucket {bucket:?}: transitions declared from undeclared state {from:?}"
);
}
for to in targets {
if ! raw_schema . states . contains_key ( to ) {
anyhow ::bail! (
"aggregates.yaml: bucket {bucket:?}: transition {from:?} -> {to:?} targets an undeclared state"
);
}
}
}
2026-09-23 19:45:48 +02:00
let mut mail_for_state = HashMap ::new ();
2026-09-25 21:35:25 +02:00
let mut grants_for_state = HashMap ::new ();
let mut after_for_state = HashMap ::new ();
2026-09-23 19:45:48 +02:00
for ( state , raw_state ) in raw_schema . states {
2026-09-25 21:35:25 +02:00
if let Some ( group ) = raw_state . grants {
if group . trim (). is_empty () {
anyhow ::bail! ( "aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group" );
}
grants_for_state . insert ( state . clone (), group );
}
if let Some ( after ) = raw_state . after {
if after . days == 0 {
anyhow ::bail! ( "aggregates.yaml: bucket {bucket:?}: state {state:?}: after.days must be at least one" );
}
// A clock may only make a move a person could have
// made, so it is held to the same graph.
if ! raw_schema . transitions . get ( & state ). is_some_and ( | tos | tos . contains ( & after . to )) {
anyhow ::bail! (
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it" ,
after . to
);
}
after_for_state . insert ( state . clone (), after );
}
2026-09-23 19:45:48 +02:00
if let Some ( mail ) = raw_state . mail {
if mail . subject . trim (). is_empty () || mail . body . trim (). is_empty () {
anyhow ::bail! ( "aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body" );
}
2026-09-30 06:55:27 +02:00
let through_a_record = crate ::mail ::parent_of ( & mail . to ). is_some ();
if ! mail . to_submitter () && ! through_a_record && ! ( mail . to . contains ( '@' ) && ! mail . to . contains ( char ::is_whitespace )) {
2026-09-23 19:45:48 +02:00
anyhow ::bail! (
2026-09-30 06:55:27 +02:00
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}" ,
2026-09-23 19:45:48 +02:00
mail . to
);
}
mail_for_state . insert ( state , mail );
}
}
2026-08-11 21:43:32 +02:00
out . insert (
bucket . clone (),
AggregateSchema {
bucket ,
initial : raw_schema . initial ,
event_for_state ,
state_for_event ,
transitions : raw_schema . transitions ,
2026-08-25 15:36:07 +02:00
attended_by : raw_schema . attended_by ,
2026-09-23 19:45:48 +02:00
mail_for_state ,
2026-09-25 21:35:25 +02:00
grants_for_state ,
after_for_state ,
2026-08-11 21:43:32 +02:00
},
);
}
Ok ( out )
2026-08-06 08:52:29 +02:00
}
/// One aggregate's current view, replayed from its event history.
#[derive(Clone, Debug)]
2026-08-11 21:43:32 +02:00
pub struct Aggregate {
2026-08-06 08:52:29 +02:00
pub id : String ,
2026-08-11 21:43:32 +02:00
pub state : String ,
2026-08-06 08:52:29 +02:00
pub payload : serde_json ::Value ,
pub sequence : u64 ,
}
2026-08-11 21:43:32 +02:00
/// Pure, no IO, never panics. An event whose type isn't declared in
/// `schema`, or that doesn't fit the transition table from wherever
/// replay has gotten to so far, is silently skipped - not an error,
/// not even the *first* recognized event has anything to be "from" yet
/// so it's always accepted. This lenience (not enforcing the
/// transition table during replay, only at write time - see
/// `transition` below) is deliberate: a corrupted or out-of-order
/// event never crashes a read, it's just ignored.
pub fn replay ( schema : & AggregateSchema , id : & str , events : & [ Event ]) -> Option < Aggregate > {
let mut current : Option < Aggregate > = None ;
2026-08-06 08:52:29 +02:00
for event in events {
2026-08-11 21:43:32 +02:00
let Some ( next ) = schema . state_for_event . get ( & event . event_type ) else {
2026-08-06 08:52:29 +02:00
continue ;
};
let accept = match & current {
None => true ,
2026-08-11 21:43:32 +02:00
Some ( agg ) => * next == agg . state || schema . allowed ( & agg . state ). iter (). any ( | s | s == next ),
2026-08-06 08:52:29 +02:00
};
if ! accept {
continue ;
}
current = Some ( Aggregate {
id : id . to_string (),
2026-08-11 21:43:32 +02:00
state : next . clone (),
2026-08-06 08:52:29 +02:00
payload : event . payload . clone (),
sequence : event . sequence ,
});
}
current
}
#[derive(Debug, Clone)]
pub enum TransitionError {
/// No creation event found for this id - nothing to transition.
UnknownAggregate ,
DisallowedTransition { from : String , to : String },
2026-08-12 22:55:36 +02:00
/// A concurrent writer advanced this aggregate past the sequence
/// this call read (see `events::store::append_event`'s CAS).
2026-08-06 08:52:29 +02:00
Conflict ,
Store ( String ),
}
impl std ::fmt ::Display for TransitionError {
fn fmt ( & self , f : & mut std ::fmt ::Formatter < '_ > ) -> std ::fmt ::Result {
match self {
Self ::UnknownAggregate => write! ( f , "unknown item" ),
Self ::DisallowedTransition { from , to } => {
write! ( f , "cannot go from {from} to {to}" )
}
Self ::Conflict => write! ( f , "item was just changed by someone else, try again" ),
Self ::Store ( e ) => write! ( f , "storage error: {e}" ),
}
}
}
fn classify_publish_err ( e : async_nats ::jetstream ::context ::PublishError ) -> TransitionError {
use async_nats ::jetstream ::context ::PublishErrorKind ;
match e . kind () {
PublishErrorKind ::WrongLastSequence | PublishErrorKind ::WrongLastMessageId => {
TransitionError ::Conflict
}
_ => TransitionError ::Store ( e . to_string ()),
}
}
/// Appends the creation event for a brand-new aggregate. `expected_seq:
/// Some(0)` (no message yet on this id's subject) doubles as a
/// create-only-once guard - a double-submit race on the same id fails
/// the same way a disallowed transition does, rather than silently
/// overwriting.
2026-08-11 21:43:32 +02:00
pub async fn create (
2026-08-06 08:52:29 +02:00
js : & async_nats ::jetstream ::Context ,
2026-08-11 21:43:32 +02:00
schema : & AggregateSchema ,
2026-08-06 08:52:29 +02:00
id : & str ,
payload : serde_json ::Value ,
occurred_at_ms : i64 ,
2026-08-11 21:43:32 +02:00
) -> Result < Aggregate , TransitionError > {
let event_type = schema
. event_for_state
. get ( & schema . initial )
. expect ( "schema.initial is validated to have an event mapping" );
2026-08-06 08:52:29 +02:00
let ack = append_event (
js ,
2026-08-11 21:43:32 +02:00
& schema . bucket ,
2026-08-06 08:52:29 +02:00
id ,
2026-08-11 21:43:32 +02:00
event_type ,
2026-08-06 08:52:29 +02:00
payload . clone (),
occurred_at_ms ,
Some ( 0 ),
)
. await
. map_err ( classify_publish_err ) ? ;
Ok ( Aggregate {
id : id . to_string (),
2026-08-11 21:43:32 +02:00
state : schema . initial . clone (),
2026-08-06 08:52:29 +02:00
payload ,
sequence : ack . sequence ,
})
}
2026-08-12 22:27:57 +02:00
/// Reconstructs a minimal one-event history for an aggregate whose
/// event log is empty but whose KV projection still knows its current
/// state - what makes wiping the EVENTS stream a recoverable
/// operation rather than one that strands every pre-wipe record in an
/// untransitionable limbo. `expected_seq: Some(0)` guards two racing
/// reseeds the same way `create` guards a double submit.
pub async fn reseed (
js : & async_nats ::jetstream ::Context ,
schema : & AggregateSchema ,
id : & str ,
state : & str ,
occurred_at_ms : i64 ,
) -> Result < (), TransitionError > {
let event_type = schema . event_for_state . get ( state ). ok_or_else ( || {
TransitionError ::Store ( format! (
"cannot reseed {id} : state {state:?} is not declared for bucket {:?} " ,
schema . bucket
))
}) ? ;
append_event (
js ,
& schema . bucket ,
id ,
event_type ,
serde_json ::json! ({ "reseeded_from_projection" : true }),
occurred_at_ms ,
Some ( 0 ),
)
. await
. map_err ( classify_publish_err ) ? ;
Ok (())
}
2026-08-06 08:52:29 +02:00
/// Loads and replays `id`'s current state, then - if `target` is
/// actually reachable from it - appends the corresponding event with
/// the aggregate's just-replayed sequence as the CAS guard.
2026-08-11 21:43:32 +02:00
pub async fn transition (
2026-08-06 08:52:29 +02:00
js : & async_nats ::jetstream ::Context ,
2026-08-11 21:43:32 +02:00
schema : & AggregateSchema ,
2026-08-06 08:52:29 +02:00
id : & str ,
2026-08-11 21:43:32 +02:00
target : & str ,
2026-08-06 08:52:29 +02:00
payload : serde_json ::Value ,
occurred_at_ms : i64 ,
2026-08-11 21:43:32 +02:00
) -> Result < Aggregate , TransitionError > {
let events = load_events ( js , & schema . bucket , id )
2026-08-06 08:52:29 +02:00
. await
. map_err ( | e | TransitionError ::Store ( e . to_string ())) ? ;
2026-08-11 21:43:32 +02:00
let current = replay ( schema , id , & events ). ok_or ( TransitionError ::UnknownAggregate ) ? ;
if ! schema . allowed ( & current . state ). iter (). any ( | s | s == target ) {
2026-08-06 08:52:29 +02:00
return Err ( TransitionError ::DisallowedTransition {
2026-08-11 21:43:32 +02:00
from : current . state . clone (),
to : target . to_string (),
2026-08-06 08:52:29 +02:00
});
}
2026-08-11 21:43:32 +02:00
// Validated by parse_aggregates_yaml: every transitions target is a
// declared state, so this is always Some given the check above.
let event_type = schema
. event_for_state
. get ( target )
. expect ( "transition target is validated to be a declared state" );
2026-08-06 08:52:29 +02:00
let ack = append_event (
js ,
2026-08-11 21:43:32 +02:00
& schema . bucket ,
2026-08-06 08:52:29 +02:00
id ,
2026-08-11 21:43:32 +02:00
event_type ,
2026-08-06 08:52:29 +02:00
payload . clone (),
occurred_at_ms ,
Some ( current . sequence ),
)
. await
. map_err ( classify_publish_err ) ? ;
Ok ( Aggregate {
id : id . to_string (),
2026-08-11 21:43:32 +02:00
state : target . to_string (),
2026-08-06 08:52:29 +02:00
payload ,
sequence : ack . sequence ,
})
}
#[cfg(test)]
mod tests {
use super ::* ;
2026-08-11 21:43:32 +02:00
fn test_schema () -> AggregateSchema {
parse_aggregates_yaml (
r #"
aggregates:
- bucket: test
initial: open
states:
open: { event: opened }
a: { event: a }
b: { event: b }
transitions:
open: [a, b]
"# ,
)
. unwrap ()
. remove ( "test" )
. unwrap ()
2026-08-06 08:52:29 +02:00
}
2026-09-23 19:45:48 +02:00
#[test]
fn a_state_may_carry_the_mail_its_arrival_sends () {
let schemas = parse_aggregates_yaml (
r #"
aggregates:
- bucket: reports
initial: open
states:
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
dropped: { event: dropped }
transitions:
open: [fixed, dropped]
"# ,
)
. unwrap ();
let s = & schemas [ "reports" ];
assert! ( s . mail_for ( "open" ). unwrap (). to_submitter ());
assert_eq! ( s . mail_for ( "fixed" ). unwrap (). to , "board@example.no" );
assert! ( s . mail_for ( "dropped" ). is_none ());
for bad in [
"mail: { to: submitter, subject: \"\" , body: b }" ,
"mail: { to: \" the board \" , subject: s, body: b }" ,
"mail: { to: submitter, subject: s, body: b, cc: x }" ,
] {
let yaml = format! ( "aggregates: \n - bucket: r \n initial: open \n states: \n open: {{ event: received, {bad} }}\n " );
assert! ( parse_aggregates_yaml ( & yaml ). is_err (), "{bad}" );
}
}
2026-08-06 08:52:29 +02:00
fn event ( seq : u64 , event_type : & str ) -> Event {
Event {
id : uuid ::Uuid ::new_v4 (),
aggregate_type : "test" . to_string (),
aggregate_id : "x" . to_string (),
event_type : event_type . to_string (),
payload : serde_json ::json! ({}),
occurred_at_ms : 0 ,
sequence : seq ,
}
}
#[test]
fn empty_history_is_no_aggregate () {
2026-08-11 21:43:32 +02:00
let schema = test_schema ();
assert! ( replay ( & schema , "x" , & []). is_none ());
2026-08-06 08:52:29 +02:00
}
#[test]
fn valid_path_replays () {
2026-08-11 21:43:32 +02:00
let schema = test_schema ();
2026-08-06 08:52:29 +02:00
let events = vec! [ event ( 1 , "opened" ), event ( 2 , "a" )];
2026-08-11 21:43:32 +02:00
let agg = replay ( & schema , "x" , & events ). unwrap ();
assert_eq! ( agg . state , "a" );
2026-08-06 08:52:29 +02:00
assert_eq! ( agg . sequence , 2 );
}
#[test]
fn disallowed_edge_is_skipped_not_fatal () {
2026-08-11 21:43:32 +02:00
// "a" then "b" - b isn't reachable from a, so the second event
2026-08-06 08:52:29 +02:00
// is silently ignored, not an error.
2026-08-11 21:43:32 +02:00
let schema = test_schema ();
2026-08-06 08:52:29 +02:00
let events = vec! [ event ( 1 , "opened" ), event ( 2 , "a" ), event ( 3 , "b" )];
2026-08-11 21:43:32 +02:00
let agg = replay ( & schema , "x" , & events ). unwrap ();
assert_eq! ( agg . state , "a" );
2026-08-06 08:52:29 +02:00
}
#[test]
fn unrecognized_event_type_is_skipped () {
2026-08-11 21:43:32 +02:00
let schema = test_schema ();
2026-08-06 08:52:29 +02:00
let events = vec! [ event ( 1 , "opened" ), event ( 2 , "bogus" ), event ( 3 , "a" )];
2026-08-11 21:43:32 +02:00
let agg = replay ( & schema , "x" , & events ). unwrap ();
assert_eq! ( agg . state , "a" );
}
#[test]
fn initial_state_not_declared_is_rejected () {
let err = parse_aggregates_yaml (
r #"
aggregates:
- bucket: bad
initial: missing
states:
open: { event: opened }
"# ,
)
. unwrap_err ();
assert! ( err . to_string (). contains ( "initial state" ));
}
#[test]
fn transition_target_not_declared_is_rejected () {
let err = parse_aggregates_yaml (
r #"
aggregates:
- bucket: bad
initial: open
states:
open: { event: opened }
transitions:
open: [nonexistent]
"# ,
)
. unwrap_err ();
assert! ( err . to_string (). contains ( "undeclared state" ));
}
#[test]
fn duplicate_event_type_is_rejected () {
let err = parse_aggregates_yaml (
r #"
aggregates:
- bucket: bad
initial: open
states:
open: { event: same }
other: { event: same }
"# ,
)
. unwrap_err ();
assert! ( err . to_string (). contains ( "used by both" ));
2026-08-06 08:52:29 +02:00
}
proptest ::proptest! {
#[test]
fn replay_never_panics_and_state_always_valid (
types in proptest ::collection ::vec (
proptest ::sample ::select ( vec! [ "opened" , "a" , "b" , "bogus" ]),
0 .. 30 ,
)
) {
2026-08-11 21:43:32 +02:00
let schema = test_schema ();
2026-08-06 08:52:29 +02:00
let events : Vec < Event > = types
. iter ()
. enumerate ()
. map ( | ( i , t ) | event ( i as u64 , t ))
. collect ();
2026-08-11 21:43:32 +02:00
let result = replay ( & schema , "x" , & events );
2026-08-06 08:52:29 +02:00
if let Some ( agg ) = result {
2026-08-11 21:43:32 +02:00
assert! ( schema . has_state ( & agg . state ));
2026-08-06 08:52:29 +02:00
assert! ( agg . sequence <= events . len () as u64 );
}
}
}
}