State Views
State Views are WebAssembly components that transform events into queryable state. They implement a fold/reduce function that processes events sequentially to build derived state, similar to materialized views in traditional databases but with custom logic.
The Evolve Function
Section titled “The Evolve Function”Every State View implements a single function with this signature:
evolve(state, event) -> stateThis function:
- Takes the current state and an event
- Returns the new state after processing the event
- Is deterministic and side-effect free
- Can be written in any language that compiles to WebAssembly
How State Views Work
Section titled “How State Views Work”Event Processing
Section titled “Event Processing”State Views process events in order:
- Start with an initial state (often empty/default)
- For each matching event, call the evolve function
- The output becomes the input for the next event
- Final state represents the materialized view
Query Temporality
Section titled “Query Temporality”State Views support two temporal modes:
Revision Time (default):
- State computed up to a specific database revision
- Shows what was known at a point in time
- Used for consistency and auditing
Effective Time:
- State computed based on events’ effective timestamps
- Shows business state at a specific moment
- Used for bi-temporal queries
Writing a State View
Section titled “Writing a State View”Rust Example
Section titled “Rust Example”Here’s a complete example of an account summary State View:
use evidentsource_functions::prelude::*;use serde::{Deserialize, Serialize};
// State type - serialized to/from JSON#[derive(Debug, Default, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pub struct AccountSummary { pub id: String, pub name: String, pub balance: f64, pub is_open: bool,}
// Event data types for parsing#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct AccountOpenedData { account_id: String, customer_name: String,}
#[derive(Deserialize)]struct AccountCreditedData { amount: f64,}
#[derive(Deserialize)]struct AccountDebitedData { amount: f64,}
// Domain event enumenum AccountEvent { Opened(AccountOpenedData), Credited(AccountCreditedData), Debited(AccountDebitedData), Closed, Unknown,}
// Parse CloudEvent into domain eventimpl TryFrom<&Event> for AccountEvent { type Error = String;
fn try_from(event: &Event) -> Result<Self, Self::Error> { match event.event_type() { "com.banking.account.opened" => { let data: AccountOpenedData = serde_json::from_slice( event.data().ok_or("Missing data")?.as_bytes() ).map_err(|e| e.to_string())?; Ok(AccountEvent::Opened(data)) } "com.banking.account.credited" => { let data: AccountCreditedData = serde_json::from_slice( event.data().ok_or("Missing data")?.as_bytes() ).map_err(|e| e.to_string())?; Ok(AccountEvent::Credited(data)) } "com.banking.account.debited" => { let data: AccountDebitedData = serde_json::from_slice( event.data().ok_or("Missing data")?.as_bytes() ).map_err(|e| e.to_string())?; Ok(AccountEvent::Debited(data)) } "com.banking.account.closed" => Ok(AccountEvent::Closed), _ => Ok(AccountEvent::Unknown), } }}
// The state view function#[evidentsource_functions::state_view(name = "account-summary")]fn evolve(mut state: AccountSummary, event: AccountEvent) -> AccountSummary { match event { AccountEvent::Opened(data) => { state.id = data.account_id; state.name = data.customer_name; state.is_open = true; } AccountEvent::Credited(data) => { state.balance += data.amount; } AccountEvent::Debited(data) => { state.balance -= data.amount; } AccountEvent::Closed => { state.is_open = false; } AccountEvent::Unknown => { // Ignore unknown events } } state}Go Example
Section titled “Go Example”package main
import ( "encoding/json"
"github.com/evidentsystems/evidentsource-sdks/go/packages/evidentsource-functions/adapters")
type AccountSummary struct { ID string `json:"id"` Name string `json:"name"` Balance float64 `json:"balance"` IsOpen bool `json:"isOpen"`}
type AccountSummaryView struct { adapters.StateViewAdapter}
func (sv *AccountSummaryView) Evolve(state []byte, event adapters.Event) ([]byte, error) { var summary AccountSummary if len(state) > 0 { json.Unmarshal(state, &summary) }
switch event.EventType() { case "com.banking.account.opened": var data struct { AccountID string `json:"accountId"` CustomerName string `json:"customerName"` } json.Unmarshal(event.Data(), &data) summary.ID = data.AccountID summary.Name = data.CustomerName summary.IsOpen = true
case "com.banking.account.credited": var data struct { Amount float64 `json:"amount"` } json.Unmarshal(event.Data(), &data) summary.Balance += data.Amount
case "com.banking.account.debited": var data struct { Amount float64 `json:"amount"` } json.Unmarshal(event.Data(), &data) summary.Balance -= data.Amount
case "com.banking.account.closed": summary.IsOpen = false }
return json.Marshal(summary)}.NET Example
Section titled “.NET Example”using EvidentSource.Functions.Adapters;using EvidentSource.Core.Domain.Events;
public record AccountSummary(string Id, string Name, decimal Balance, bool IsOpen);
public abstract record AccountEvent{ public sealed record Opened(string AccountId, string CustomerName) : AccountEvent; public sealed record Credited(decimal Amount) : AccountEvent; public sealed record Debited(decimal Amount) : AccountEvent; public sealed record Closed : AccountEvent; public sealed record Unknown : AccountEvent;}
public sealed class AccountSummaryView : JsonStateViewAdapter<AccountSummary, AccountEvent>{ protected override AccountSummary InitialState() => new AccountSummary("", "", 0m, false);
protected override AccountEvent? ParseEvent(Event evt) => evt.EventType switch { "com.banking.account.opened" => ParseEventData<OpenedData>(evt) is { } d ? new AccountEvent.Opened(d.AccountId, d.CustomerName) : null, "com.banking.account.credited" => ParseEventData<CreditedData>(evt) is { } d ? new AccountEvent.Credited(d.Amount) : null, "com.banking.account.debited" => ParseEventData<DebitedData>(evt) is { } d ? new AccountEvent.Debited(d.Amount) : null, "com.banking.account.closed" => new AccountEvent.Closed(), _ => new AccountEvent.Unknown() };
protected override AccountSummary Evolve(AccountSummary state, AccountEvent evt) => evt switch { AccountEvent.Opened o => state with { Id = o.AccountId, Name = o.CustomerName, IsOpen = true }, AccountEvent.Credited c => state with { Balance = state.Balance + c.Amount }, AccountEvent.Debited d => state with { Balance = state.Balance - d.Amount }, AccountEvent.Closed => state with { IsOpen = false }, _ => state };
private record OpenedData(string AccountId, string CustomerName); private record CreditedData(decimal Amount); private record DebitedData(decimal Amount);}State View Configuration
Section titled “State View Configuration”State Views are configured via a metadata.json file:
{ "name": "account-summary", "version": 1, "description": "Account balance and status summary", "eventSelector": { "type": "or", "selectors": [ { "type": "eventTypeEquals", "value": "com.banking.account.opened" }, { "type": "eventTypeEquals", "value": "com.banking.account.credited" }, { "type": "eventTypeEquals", "value": "com.banking.account.debited" }, { "type": "eventTypeEquals", "value": "com.banking.account.closed" } ] }, "queryTemporality": "revisionTime"}Event Selectors
Section titled “Event Selectors”Control which events are processed:
// Match specific event types{ "type": "eventTypeEquals", "value": "com.banking.account.opened"}
// Match event types by prefix{ "type": "eventTypeStartsWith", "value": "com.banking.account."}
// Match events by subject{ "type": "subjectEquals", "value": "acct-001"}
// Combine selectors with OR{ "type": "or", "selectors": [ { "type": "eventTypeEquals", "value": "OrderCreated" }, { "type": "eventTypeEquals", "value": "OrderShipped" } ]}
// Combine selectors with AND{ "type": "and", "selectors": [ { "type": "subjectStartsWith", "value": "acct-" }, { "type": "eventTypeEquals", "value": "com.banking.account.credited" } ]}Building WASM Components
Section titled “Building WASM Components”rustup target add wasm32-wasip2cargo build --release --target wasm32-wasip2Go (TinyGo)
Section titled “Go (TinyGo)”# Requires TinyGo 0.40.1+tinygo build -target=wasip2 -o state-view.wasm ..NET (Windows only)
Section titled “.NET (Windows only)”dotnet workload install wasi-experimentaldotnet publish -c Release -p:BuildingForWasm=trueDeploying State Views
Section titled “Deploying State Views”Upload the WASM component to the server:
curl -X POST http://localhost:3000/api/v1/databases/banking/state-views/account-summary/versions/1 \ --data-binary @target/wasm32-wasip2/release/account_summary.wasm \ -H "Content-Type: application/wasm"Querying State Views
Section titled “Querying State Views”Via REST API
Section titled “Via REST API”# Get state view without parameterscurl http://localhost:3000/api/v1/databases/banking/state-views/order-summary/versions/1
# Get state view with parameterscurl "http://localhost:3000/api/v1/databases/banking/state-views/account-summary/versions/1?account_id=acct-001"
# Get state at a specific revisioncurl "http://localhost:3000/api/v1/databases/banking/state-views/account-summary/versions/1?account_id=acct-001&revision=500"Via gRPC (TypeScript)
Section titled “Via gRPC (TypeScript)”import { EvidentSource, databaseName, stateViewName, stateViewContentAsJson, stringAttribute,} from "@evidentsource/client";
const es = EvidentSource.connect("http://localhost:50051");const conn = await es.connectDatabase(databaseName("banking"));const db = await conn.latestDatabase();
// Query with parametersconst params = new Map([["account_id", stringAttribute("acct-001")]]);const view = await db.viewStateWithParams( stateViewName("account-summary"), 1, params);
if (view) { interface AccountSummary { id: string; name: string; balance: number; isOpen: boolean; } const account = stateViewContentAsJson<AccountSummary>(view); console.log(`Balance: $${account.balance.toFixed(2)}`);}Via gRPC (Rust)
Section titled “Via gRPC (Rust)”use evidentsource_client::EvidentSource;use evidentsource_core::domain::{DatabaseName, StateViewName, EventAttribute};
let es = EvidentSource::connect_to_server("http://localhost:50051").await?;let db_name = DatabaseName::new("banking")?;let conn = es.connect(&db_name).await?;
// Query the state viewlet sv_name = StateViewName::new("account-summary")?;let account: Option<AccountSummary> = conn .local_database() .view_state_opt(&sv_name, 1) .await?;
if let Some(acc) = account { println!("Balance: ${:.2}", acc.balance);}Bi-Temporal Queries
Section titled “Bi-Temporal Queries”Query state at different points in time:
Revision Time (Point-in-Time)
Section titled “Revision Time (Point-in-Time)”// Get database at a specific revisionconst historicalDb = await conn.databaseAtRevision(500n);
// Query state as it was known at that revisionconst pastView = await historicalDb.viewStateWithParams( stateViewName("account-summary"), 1, params);Effective Time
Section titled “Effective Time”For state views configured with effectiveTime query temporality:
# Query state as of a specific effective timecurl "http://localhost:3000/api/v1/databases/banking/state-views/account-summary/versions/1\?account_id=acct-001&effective_time=2024-01-15T00:00:00Z"Testing State Views
Section titled “Testing State Views”Use test utilities to verify evolve logic:
#[cfg(test)]mod tests { use super::*; use evidentsource_functions::testing::TestEventBuilder;
#[test] fn test_account_opened() { let state = AccountSummary::default();
let event = TestEventBuilder::new("com.banking.account.opened") .subject("acct-001") .json_data(&AccountOpenedData { account_id: "acct-001".to_string(), customer_name: "Alice".to_string(), }) .build();
let domain_event = AccountEvent::try_from(&event).unwrap(); let new_state = evolve(state, domain_event);
assert_eq!(new_state.id, "acct-001"); assert_eq!(new_state.name, "Alice"); assert!(new_state.is_open); }
#[test] fn test_balance_calculation() { let mut state = AccountSummary { id: "acct-001".to_string(), name: "Alice".to_string(), balance: 100.0, is_open: true, };
// Credit let credit = TestEventBuilder::new("com.banking.account.credited") .json_data(&AccountCreditedData { amount: 50.0 }) .build(); state = evolve(state, AccountEvent::try_from(&credit).unwrap()); assert_eq!(state.balance, 150.0);
// Debit let debit = TestEventBuilder::new("com.banking.account.debited") .json_data(&AccountDebitedData { amount: 30.0 }) .build(); state = evolve(state, AccountEvent::try_from(&debit).unwrap()); assert_eq!(state.balance, 120.0); }}Best Practices
Section titled “Best Practices”State Design
Section titled “State Design”- Keep state small: Large states impact query performance
- Use efficient serialization: JSON for simplicity, binary for performance
- Design for queries: Structure state to match query patterns
- Handle missing data: Use sensible defaults
Event Handling
Section titled “Event Handling”- Be defensive: Handle malformed event data gracefully
- Ignore unknown events: Return state unchanged for unrecognized types
- Avoid side effects: Evolve must be pure and deterministic
- Log don’t fail: Log parsing errors but don’t crash
Performance
Section titled “Performance”- Filter early: Use precise event selectors
- Minimize allocations: Reuse data structures where possible
- Consider partitioning: Use parameters to scope state views
Integration with State Changes
Section titled “Integration with State Changes”State Views and State Changes form a feedback loop:
- State Change queries State View for current state
- State Change validates command against current state
- State Change emits events
- State View processes events to update state
- Next State Change sees updated state
This pattern enables:
- Idempotency checking
- Duplicate detection
- Business rule validation
- Aggregate enforcement
Next Steps
Section titled “Next Steps”- Learn about State Changes for command processing
- Explore bi-temporal queries for historical analysis
- See DCB constraints for consistency patterns
- Review API Reference for deployment details