# > | = ~ @ ?

Reactive Streams

Reactive streams are syntax, not a library. ~ declares them, |> composes them, ~ + | consumes them.

Creating Streams

From a list

~ nums = from_list([1, 2, 3, 4, 5])
~ letters = from_list(["a", "b", "c"])

From a range

~ nums = from_list(range(1, 11))    -- [1, 2, 3, ..., 10]

From a subject (push-based)

~ clicks = subject()
clicks <- "click1"
clicks <- "click2"

Stream Operators

All stream operators take the stream as the first argument. Use directly or with |>.

Transformation

OperatorSignatureDescription
map(Stream(a), a -> b) -> Stream(b)Transform each element
flat_map(Stream(a), a -> Stream(b)) -> Stream(b)Map and flatten
enumerateStream(a) -> Stream((Int, a))Attach index to each element
~ doubled = nums |> map(|x| x * 2)
~ indexed = nums |> enumerate

Filtering

OperatorSignatureDescription
filter(Stream(a), a -> Bool) -> Stream(a)Keep elements where predicate is true
take(Stream(a), Int) -> Stream(a)Take first N elements
skip(Stream(a), Int) -> Stream(a)Skip first N elements
distinctStream(a) -> Stream(a)Remove consecutive duplicates
~ big = nums |> filter(|x| x > 3)
~ first3 = nums |> take(3)
~ after2 = nums |> skip(2)

Accumulation

OperatorSignatureDescription
scan(Stream(a), b, (b, a) -> b) -> Stream(b)Running fold, emitting each accumulator
~ running_sum = nums |> scan(0, |acc, x| acc + x)
-- emits: [1, 3, 6, 10, 15]

Combination

OperatorSignatureDescription
merge(Stream(a), Stream(a)) -> Stream(a)Interleave two streams
zip(Stream(a), Stream(b)) -> Stream((a, b))Pair elements by position
combine_latest(Stream(a), Stream(b)) -> Stream((a, b))Combine with latest value from each
~ merged = merge(odds, evens)
~ pairs = zip(names, scores)

Windowing

OperatorSignatureDescription
window(Stream(a), Int) -> Stream(List(a))Sliding window of size N
~ windows = nums |> window(3)
-- emits: [[1,2,3], [2,3,4], [3,4,5]]

Terminal operations

These consume the stream and return a single value.

OperatorSignatureDescription
countStream(a) -> IntCount elements
sumStream(Int) -> IntSum all elements
lastStream(a) -> aLast element; raises last: empty list when empty
any(Stream(a), a -> Bool) -> BoolAny element matches?
all(Stream(a), a -> Bool) -> BoolAll elements match?
= total = nums |> sum
= count = nums |> count
= has_big = nums |> any(|x| x > 100)

Side Effects & Error Recovery

OperatorSignatureDescription
tap(Stream(a), a -> ()) -> Stream(a)Side-effect observation: calls fn for each element, returns stream unchanged
catch(Stream(a), Err -> Stream(a)) -> Stream(a)Error recovery: in sync mode, pass-through (no errors in Vec)
~ result = raw_data
    |> tap(|x| @ print("saw: " + show(x)))    -- observe without consuming
    |> catch(|e| from_list([fallback_value]))   -- recover mid-pipeline
    |> map(transform)

tap observes values passing through without consuming the stream. catch recovers from errors mid-pipeline, replacing the failed portion with a recovery stream. Both return the stream for further chaining — they are NOT terminals.

Prepending & Concatenation

OperatorSignatureDescription
start_with(Stream(a), a) -> Stream(a)Prepend a value to the front of a stream
concat(Stream(a), Stream(a)) -> Stream(a)Concatenate two streams sequentially
~ nums = from_list([2, 3, 4])
~ with_one = nums |> start_with(1)         -- [1, 2, 3, 4]
~ both = concat(from_list([1, 2]), from_list([3, 4]))  -- [1, 2, 3, 4]

Pairing & Tuple Access

OperatorSignatureDescription
pairwiseStream(a) -> Stream((a, a))Emit consecutive pairs: [1,2,3] becomes [(1,2),(2,3)]
fst(a, b) -> aReturn first element of a tuple/pair
snd(a, b) -> bReturn second element of a tuple/pair
~ nums = from_list([1, 2, 3, 4])
~ pairs = nums |> pairwise               -- [(1,2), (2,3), (3,4)]
~ firsts = pairs |> map(|p| fst(p))      -- [1, 2, 3]
~ seconds = pairs |> map(|p| snd(p))     -- [2, 3, 4]

Additional Terminal Operations

OperatorSignatureDescription
firstStream(a) -> aFirst element; raises first: empty list when empty
reduce(Stream(a), b, (b, a) -> b) -> bTerminal fold: reduce stream to a single value
~ nums = from_list([10, 20, 30])
= head = nums |> first                           -- 10
= total = nums |> reduce(0, |acc, x| acc + x)    -- 60

Pipe Operator (|>)

The pipe operator inserts the left side as the first argument of the right side:

x |> f           -- f(x)
x |> f(a, b)     -- f(x, a, b)
x |> f |> g      -- g(f(x))

Chains compose naturally:

~ result = from_list(range(1, 101))
    |> filter(|x| x % 2 == 0)
    |> map(|x| x * x)
    |> take(10)

Subjects (Push-Based Streams)

Subjects are mutable streams you can push values into.

Creation

~ clicks = subject()              -- empty subject
~ temp = subject(20.0)            -- with initial value (BehaviorSubject)
~ history = subject(0, 10)        -- replay last 10 values (ReplaySubject, positional)

Pushing values

clicks <- "event1"
clicks <- "event2"
temp <- 25.0

Properties

clicks.count       -- number of values in the subject
temp.latest        -- most recent value

Using subjects as streams

Subjects work anywhere a stream does:

~ data = subject()
data <- 1
data <- 2
data <- 3

~ doubled = data |> map(|x| x * 2)
= total = data |> sum           -- 6

Converting to read-only stream

~ stream = as_stream(my_subject)

Completing a subject

complete(my_subject)               -- mark as done
error(my_subject, "something failed")  -- terminate with error

Scopes (Lifecycle Management)

Scopes group stream operations with lifecycle management:

| scope Dashboard {
    ~ readings = subject()
    ~ alerts = readings |> filter(|r| r.severity > 3)

    ~ alerts
        | a -> { notify(a.message) }
        | Err(e) -> { log_error(e) }
}
-- Dashboard exits -> all subscriptions torn down, zero leaks

When a scope ends, its subjects, streams, and subscriptions are cleaned up. Named scopes are also the explicit lifetime owner for live subscriptions started inside ordinary functions. See docs/stream-lifetimes.md for the full contract.


Subscriptions (~ + |)

Subscriptions are the terminal consumption mechanism for streams. They use ~ to open the subscription and | arms to handle stream events: values, errors, and completion.

Why not for?

for is pull-based with no error/completion handling. ~ + | maps directly to the three stream events and compiles to a tokio broadcast receive loop. Use for for lists and ranges; use ~ + | for streams.

The three stream events

Every stream produces exactly three kinds of events:

EventMeaningTokio mapping
ValueA new value arrivedOk(x) from rx.recv().await
ErrorSomething went wrongErr(RecvError::Lagged(n))
CompleteStream has endedErr(RecvError::Closed)

The ~ + | arms correspond directly to these three events.

Syntax forms

Single handler (most common)

~ stream | x -> { handle(x) }

One arm, handles each value. Errors silently terminate the subscription (same as current for behavior, but explicit). Use this when you don't care about errors.

With error handling

~ stream
    | x -> { handle(x) }
    | Err(e) -> { recover(e) }

Two arms. Err(e) catches stream errors. The subscription continues after handling the error (the error is not fatal to the subscription).

Full lifecycle

~ stream
    | x -> { handle(x) }
    | Err(e) -> { recover(e) }
    | Complete -> { finalize() }

Three arms. Complete fires when the stream ends normally (all senders dropped, or complete(subject) called). This is the full form — every stream event is handled.

Pipeline ending in subscription

~ sensor |> filter(valid) |> map(to_celsius)
    | t -> { display(t) }
    | Err(e) -> { log(e) }

Pipe operators transform the stream; | arms subscribe to the result. The pipeline composes transformations, the subscription consumes them.

Pipeline with mid-stream recovery AND terminal handling

~ api_data
    |> catch(|e| from_list([fallback]))    -- recover mid-pipeline
    |> map(transform)
    | result -> { save(result) }              -- consume at terminal
    | Err(e) -> { alert(e) }                 -- only uncaught errors reach here

catch recovers within the pipeline (the stream continues). | Err(e) handles errors that survive the pipeline.

Binding and subscription are separate statements

A ~ statement is either a binding or a subscription, never both:

-- Binding: ~ name = expr
~ temps = sensor |> map(to_celsius)

-- Subscription: ~ expr | arms
~ temps
    | t -> { display(t) }
    | Err(e) -> { log(e) }

The = determines binding vs subscription. ~ name = expr binds. ~ expr | arms subscribes.

This means you can subscribe to the same stream multiple times:

~ temps = sensor |> map(to_celsius)

-- Two independent subscriptions
~ temps | t -> { display(t) }
~ temps | t -> { log_to_file(t) }

And you can subscribe to an inline pipeline without naming it:

~ sensor |> filter(valid) |> map(to_celsius)
    | t -> { display(t) }

Scoped subscriptions

Inside a | scope block, subscriptions are torn down when the scope exits:

| scope WeatherApp {
    ~ readings = subject()
    ~ alerts = readings |> filter(|r| r.severity > 3)

    -- Both subscriptions die when WeatherApp scope exits
    ~ alerts
        | a -> { notify(a.message) }
        | Err(e) -> { log_error(e) }
        | Complete -> { @ print("stream ended") }

    ~ readings
        | r -> { update_dashboard(r) }
}
-- Scope exits -> all subscriptions cancelled, channels closed

Function boundaries

Ordinary functions may not start live subscriptions unless a named scope owns them:

> install_bad(readings) -> () {
    ~ readings | x -> { @ print(show(x)) }   -- compile error
}

> install_ok(readings) -> () {
    | scope Monitor {
        ~ readings | x -> { @ print(show(x)) }
    }
}

The same rule applies to for x in stream { ... } when stream is a live subject or derived async stream. This keeps compiled async subscriptions from quietly outliving the function that created them.

Futuruna does not currently expose returned subscription handles. Factor advanced stream setup by returning a stream expression, then subscribe inside the caller's named scope. Use @ teardown("ScopeName") when the scope must be cancelled before normal scope exit. See Stream Lifetimes for the full ownership contract and the deferred handle design.

Migrating from for

Before (for loop)After (subscription)
for x in stream { body }~ stream | x -> { body }
for x in stream { if err { handle } }~ stream | x -> { body } | Err(e) -> { handle }
for x in list { body }for x in list { body } (unchanged)

for remains correct for lists and ranges. Only stream consumption migrates to ~ + |.

Design rationale

~ means "what flows" — subscription is where flow meets action. | arms use the same | pattern -> { body } syntax as match. Stream events are just another thing to pattern match on. The three arms map 1:1 to tokio broadcast recv() outcomes — no wrappers, no indirection.

Pipeline operators and subscription arms are complementary: catch recovers within the pipeline (stream continues), | Err handles errors at the terminal (subscription level), tap observes mid-pipeline, | x -> consumes at the terminal.