PropertyDamage.Model.Projection behaviour (PropertyDamage v0.2.0)

View Source

Behaviour for projections that track state and optionally define assertions.

Projections are the core building block for stateful property-based testing. They serve two purposes:

  1. State tracking: Reduce commands and events into state via apply/2
  2. Invariant checking: Define assertions via @trigger and @poll_state

Basic Usage

defmodule MyProjection do
  use PropertyDamage.Model.Projection

  # Track state
  def init, do: %{orders: %{}, total: 0}

  def apply(state, %OrderCreated{id: id, amount: amt}) do
    state
    |> put_in([:orders, id], %{amount: amt})
    |> update_in([:total], &(&1 + amt))
  end

  def apply(state, _), do: state

  # Synchronous assertion - runs immediately when event occurs
  @trigger every: 1
  def assert_total_non_negative(state, _cmd_or_event) do
    if state.total < 0, do: PropertyDamage.fail!("total is negative", total: state.total)
  end

  @trigger every: CreateOrder
  def assert_order_tracked(state, %CreateOrder{id: id}) do
    unless Map.has_key?(state.orders, id) do
      PropertyDamage.fail!("order not tracked", order_id: id)
    end
  end
end

Assertion Types

There are two types of assertions:

Synchronous Assertions (@trigger)

Run immediately when the trigger condition is met. Use for invariants that should hold right after a command/event is processed.

@trigger every: 1
def assert_balance_positive(state, _cmd_or_event) do
  if state.balance < 0 do
    PropertyDamage.fail!("balance is negative", balance: state.balance)
  end
end

@trigger also supports an at: timing for one-shot checks at a lifecycle boundary (at: :startup / at: :teardown); see "Lifecycle-Boundary Assertions" below. Use at: :teardown for safety properties on the settled final state.

Temporal Assertions (@poll_state)

Spawn a background poller when a trigger event occurs. The poller periodically checks if a predicate becomes true within a timeout. Use for eventual consistency assertions.

@poll_state after: PaymentInitiated, timeout: 5, interval: {100, :milliseconds}
def payment_confirmed(_state, %PaymentInitiated{id: id}) do
  fn s -> s.payments[id] == :confirmed end
end

Defining Assertions

Assertions are functions that take two arguments:

  1. state - The current projection state
  2. command_or_event - The command or event that triggered the assertion

Each assertion must be preceded by either a @trigger or @poll_state attribute. Both @trigger and @poll_state functions can have any name. The assert_ prefix is optional and conventional but not required.

If a synchronous assertion fails, raise an exception (or use PropertyDamage.fail!/2). If it returns without raising, the assertion passed.

For @poll_state assertions, the function must return a predicate function (state -> boolean) that will be polled.

Raising in apply/2

You can raise exceptions in apply/2 to catch transition invariants:

def apply(state, %Withdraw{amount: amt}) do
  new_balance = state.balance - amt
  if new_balance < 0 do
    raise %InsufficientFunds{balance: state.balance, requested: amt}
  end
  %{state | balance: new_balance}
end

@trigger Syntax

Use @trigger with every: to specify when a synchronous assertion runs:

SyntaxRuns when...
@trigger every: 1After every step
@trigger every: :commandAfter any command
@trigger every: :eventAfter any event
@trigger every: CreateOrderAfter CreateOrder command/event
@trigger every: [Cmd1, Cmd2]After any listed command/event
@trigger every: 10Every 10th step (sampling)
@trigger every: {5, :command}Every 5th command
@trigger every: {3, CreateOrder}Every 3rd CreateOrder

A step is each unit processed in the execution stream: every command AND every event increments the step counter. So every: 1 runs after each command and after each of its events, while every: {5, :command} counts commands only. The count in {N, target} must be a positive integer.

Lifecycle-Boundary Assertions (@trigger at:)

@trigger has a second, orthogonal timing axis: at:. Where every: samples an assertion during the command loop, at: fires it exactly once at a lifecycle phase boundary. An assertion carries exactly one timing: every: xor at: (declaring both is a compile error).

SyntaxRuns...
@trigger at: :startuponce on the initial init/0 state, after setup/1, before command 1
@trigger at: :teardownonce on the fully-settled final state, after all pollers finalize, before teardown/1

Because no command or event triggers a lifecycle-boundary assertion, the second argument is the phase atom (:startup or :teardown); a state-only check ignores it:

@trigger at: :teardown
def assert_balance_reconciles(state, _phase) do
  if state.debits != state.credits do
    PropertyDamage.fail!("ledger did not reconcile", state: state)
  end
end

at: :teardown is the natural home for a safety property ("this never happens too much"), the temporal dual of @poll_state's liveness ("this eventually happens"). "Settled" means after both the state pollers (@poll_state) and the resource pollers have finalized: the one point in a run where no poller is live and every observed event has been folded into projection state. A @poll_state liveness timeout preempts the :teardown checkpoint (a timeout is itself a not-settled outcome). A failing :startup check halts the run before the first command.

The accumulator contract

A :teardown check runs on the final folded state, so detection depends on the projection retaining evidence of a violation. Write a safety projection to accumulate (track a maximum observed value, a sticky violated? flag, an application count) rather than snapshot the latest value. A snapshot projection that heals back to a legal value before settling silently misses a transient over-application:

# GOOD — accumulates: the overshoot leaves a permanent trace.
def apply(%{count: c, max: m} = s, %Applied{}), do: %{s | count: c + 1, max: max(m, c + 1)}

@trigger at: :teardown
def assert_at_most_once(state, _phase) do
  if state.max > 1, do: PropertyDamage.fail!("applied more than once", max: state.max)
end

# BAD — snapshots: a 0 -> 2 -> 1 transient is invisible at settle.
def apply(%{count: c} = s, %Applied{}), do: %{s | count: c + 1}
def apply(%{count: c} = s, %Reverted{}), do: %{s | count: c - 1}

@poll_state Syntax

Use @poll_state with the following options:

OptionTypeDescription
after:module or [modules]Event(s) that spawn the poller
timeout:integer or {int, unit}Max time to poll (integer = seconds)
interval:integer or {int, unit}Polling frequency (integer = seconds)

Time units (singular or plural): :millisecond(s), :second(s), :minute(s)

Example:

@poll_state after: PaymentInitiated, timeout: 5, interval: {100, :milliseconds}
def payment_confirmed(_state, %PaymentInitiated{id: id}) do
  fn s -> s.payments[id] == :confirmed end
end

Simplified Usage (No State)

For assertions that only inspect commands/events, skip init/0 and apply/2:

defmodule CommandValidator do
  use PropertyDamage.Model.Projection

  @trigger every: CreateOrder
  def assert_order_has_items(_state, %CreateOrder{items: items}) do
    if Enum.empty?(items), do: PropertyDamage.fail!("order must have items")
  end
end

Model Configuration

In your Model, specify projections:

def command_sequence_projection, do: MyStateProjection    # required
def assertion_projections, do: [Validator, Audit]  # optional

All projections (state + extra) use the same Projection behaviour.

Summary

Callbacks

Apply a command or event to the state.

Initialize the projection state.

Functions

Execute an assertion.

Check if an event matches a polling trigger.

Check if an assertion should run given the current step context.

Callbacks

apply(state, command_or_event)

(optional)
@callback apply(state :: any(), command_or_event :: struct()) :: any()

Apply a command or event to the state.

Called for each command and event in the execution stream. Can raise an exception to signal a transition invariant violation. Default returns the state unchanged.

init()

(optional)
@callback init() :: any()

Initialize the projection state.

Called once at the start of each test run. Default returns %{}.

Functions

__using__(opts)

(macro)

Execute an assertion.

Assertions are functions decorated with @trigger or @poll_state. Called when the assertion's trigger condition is met. The assert_ prefix is conventional but not required; when present it is stripped from the assertion's reported :name (so def assert_total_ok is reported as :total_ok) while the full function name is kept internally for dispatch. Exactly one @trigger or @poll_state may decorate an assertion, never both and never more than one. Should raise an exception if the assertion fails. If the function returns without raising, the assertion passed.

Example

@trigger every: 1
def assert_total_non_negative(state, _cmd_or_event) do
  if state.total < 0, do: PropertyDamage.fail!("total is negative")
end

Parameters

  • state - Current projection state
  • command_or_event - The command or event that triggered this assertion

event_matches_poll_trigger?(poll_state, event_module)

@spec event_matches_poll_trigger?(map(), module()) :: boolean()

Check if an event matches a polling trigger.

Used by the executor to determine if a @poll_state assertion should spawn a poller when an event is processed.

Parameters

  • poll_state - Normalized poll_state spec from assertion metadata
  • event_module - The module of the event being processed

Returns

true if the poller should be spawned, false otherwise.

should_run?(trigger, step_type, module, counters)

@spec should_run?(map(), :command | :event, module(), map()) :: boolean()

Check if an assertion should run given the current step context.

Parameters

  • trigger - Normalized trigger from assertion metadata
  • step_type - :command or :event
  • module - The command or event module
  • counters - Map with :step, :command, :event, and per-module counts

Returns

true if the assertion should run, false otherwise.