git.iko.soy/rowtype-yoga/purescript-yoga-om-strom
on main help sign in
git clone ssh://git.iko.soy/rowtype-yoga/purescript-yoga-om-strom

yoga-om-strom

Strom is a powerful, ZIO ZStream-inspired streaming library for yoga-om that brings pull-based, composable, and resource-safe stream processing to PureScript.

Why "Strom"?

Just as Om represents your effect type, Strom represents streaming effects. The name comes from German/Swedish meaning "stream" or "current" - fitting for a streaming library!

Features

๐ŸŽฏ ZIO ZStream-Inspired

Strom brings the best ideas from Scala's ZIO ZStream to PureScript:

๐Ÿš€ Comprehensive Operations

Installation

spago install yoga-om-core yoga-om-strom

Quick Start

Basic Stream Operations

import Yoga.Om.Strom as Strom

-- Simple transformation pipeline
result <- 
  Strom.range 1 10
    # Strom.map (_ * 2)
    # Strom.filter (_ > 10)
    # Strom.runCollect

-- Output: [12, 14, 16, 18]

Do-Notation (Comprehension-Style!)

import Yoga.Om.Strom.Do (guard)

-- Pythagoras triples
triples <-
  (do
    a <- Strom.range 1 20
    b <- Strom.range a 20
    c <- Strom.range b 20
    guard (a * a + b * b == c * c)
    pure (Tuple a (Tuple b c))
  ) # Strom.runCollect

-- Output: [(3,4,5), (5,12,13), (6,8,10), (8,15,17), ...]

See DO_NOTATION.md for comprehensive examples!

Effectful Operations

-- Map with monadic effects
users <-
  Strom.range 1 100
    # Strom.mapM (\id -> fetchUser id)  -- Om ctx err User
    # Strom.filter (_.active)
    # Strom.runCollect

Parallel Processing

-- Process up to 10 items concurrently
results <-
  Strom.fromArray urls
    # Strom.mapParallel 10 fetchUrl
    # Strom.runCollect

Stateful Transformations

-- Running total with scan
totals <-
  Strom.fromArray [1, 2, 3, 4, 5]
    # Strom.scan (+) 0
    # Strom.runCollect
-- Output: [1, 3, 6, 10, 15]

-- Stateful map with accumulator
indexed <-
  Strom.fromArray ["a", "b", "c"]
    # Strom.mapAccum (\i x -> Tuple (i + 1) (show i <> ": " <> x)) 1
    # Strom.runCollect
-- Output: ["1: a", "2: b", "3: c"]

Combining Streams

-- Zip two streams
pairs <-
  Strom.zip
    (Strom.range 1 5)
    (Strom.fromArray ["a", "b", "c", "d", "e"])
  # Strom.runCollect
-- Output: [(1, "a"), (2, "b"), (3, "c"), (4, "d"), (5, "e")]

-- Interleave deterministically
mixed <-
  Strom.interleave
    (Strom.fromArray [1, 3, 5])
    (Strom.fromArray [2, 4, 6])
  # Strom.runCollect
-- Output: [1, 2, 3, 4, 5, 6]

-- Merge non-deterministically (race-based)
merged <-
  Strom.merge stream1 stream2
  # Strom.runCollect

Infinite Streams

-- Take from infinite stream
first10 <-
  Strom.iterate (_ + 1) 0
    # Strom.take 10
    # Strom.runCollect
-- Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

-- Fibonacci sequence
fibonacci <-
  Strom.unfold
    (\(Tuple a b) -> Just (Tuple a (Tuple b (a + b))))
    (Tuple 0 1)
  # Strom.take 10
  # Strom.runCollect
-- Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Grouping and Batching

-- Process in batches
Strom.range 1 100
  # Strom.grouped 10
  # Strom.traverse_ (\batch -> do
      Console.log $ "Processing batch of " <> show (length batch)
      processBatch batch
    )

Error Handling

-- Catch and recover
results <-
  riskyStream
    # Strom.catchAll (\err -> Strom.succeed defaultValue)
    # Strom.runCollect

-- Provide alternative stream
results <-
  primaryStream `Strom.orElse` fallbackStream
  # Strom.runCollect

Real-World Example

Event Processing Pipeline

type Event = { timestamp :: Int, userId :: String, action :: String }
type Context = { logger :: String -> Aff Unit, db :: Database }

processEvents :: Om Context () Unit
processEvents = do
  fetchEventStream
    # Strom.filter (_.action /= "spam")           -- Filter spam
    # Strom.changes                                -- Deduplicate consecutive
    # Strom.mapM enrichEvent                       -- Fetch additional data
    # Strom.mapParallel 5 validateEvent            -- Validate concurrently
    # Strom.collect identity                       -- Keep only valid (Maybe)
    # Strom.grouped 50                             -- Batch for DB
    # Strom.tapM (\batch -> logBatch batch)        -- Log each batch
    # Strom.traverse_ (\batch -> saveBatch batch)  -- Save to DB

Paginated API Fetching

-- Unfold paginated results into a stream
fetchAllPages :: Om Context () (Array Item)
fetchAllPages = do
  Strom.unfoldOm (\pageToken -> do
    page <- fetchPage pageToken
    case page.nextToken of
      Nothing -> pure Nothing
      Just token -> pure $ Just $ Tuple page.items token
  ) initialToken
    >>= Strom.fromArray  -- Flatten pages
    # Strom.runCollect

Rate-Limited API Calls

-- Process with delays between items
processWithRateLimit :: Om Context () Unit
processWithRateLimit = do
  Strom.fromArray items
    # Strom.mapM (\item -> do
        result <- callAPI item
        Om.delay (Milliseconds 100.0)  -- Rate limit
        pure result
      )
    # Strom.traverse_ handleResult

Comparison with Other Approaches

vs Arrays

-- โŒ Arrays: Load everything into memory
processArrays :: Om ctx err (Array Result)
processArrays = do
  items <- fetchAllItems  -- Loads ALL items
  Array.traverse processItem items  -- Sequential

-- โœ… Strom: Efficient streaming
processStream :: Om ctx err Unit
processStream = do
  Strom.unfoldOm fetchNextBatch initialState
    >>= Strom.fromArray
    # Strom.mapParallel 10 processItem  -- Parallel!
    # Strom.runDrain

vs Bolson Events

-- Bolson Events are push-based (FRP)
-- Great for UI, but less control over backpressure

-- Strom is pull-based
-- Consumer controls when to pull next items
-- Better for batch processing, APIs, file I/O

API Overview

Construction

Running

Transformations

Selection

Combining

Grouping

Error Handling

Performance Characteristics

Benchmark Results (Node.js v25)

Performance benchmarks on a modern system (median of 10 runs):

OperationDatasetMedian Time
Simple Ops
map2M elements<1ms
filter (50%)2M elements<1ms
map chain (3x)2M elements<1ms
pipeline (map+filter+map)2M elements<1ms
Aggregation
fold (sum)1M elements<1ms
collect50k elements<1ms
scan (running sum)2M elements<1ms
Construction
fromArray1M elements<1ms
iterateStrom10k elements<1ms
repeatStrom10k elements<1ms
unfold10k elements<1ms
iterateInfinite + take10k elements1ms
Selection
take5k from 2M<1ms
takeWhile~50k from 1M<1ms
drop5k from 1M<1ms
dropWhile500k from 1M<1ms
Transform
mapM (effect)50k elements<1ms
tap50k elements<1ms
collect (filter+map)50k elements<1ms
changes (dedup)50k elements<1ms
Grouping
grouped (chunks of 100)50k elements<1ms
partition (even/odd)50k elements<1ms
Combining
append2x500k streams<1ms
concat10x10k streams<1ms
zip2x500k streams<1ms
zipWith2x500k streams<1ms
bind (flatMap)5kร—10<1ms
Concurrent (Pure)
mergeND (pure functions)2x1M streams485ms โš ๏ธ
mapPar (pure, concurrency=4)5k elements355ms โš ๏ธ
mergeND (with 1ms delays)2x100 elements~200ms โœ…
mapPar (with 10ms delays, conc=4)100 elements~250ms โœ…

Key Takeaways:

Architecture

Strom is built on a simple but powerful abstraction:

newtype Strom ctx err a = Strom
  { pull :: Om ctx err (Step (Strom ctx err a) (Maybe (Chunk a)))
  }

type Chunk a = Array a

Each pull:

  1. Returns a Step - either Done (finished) or Loop (continue)
  2. May emit a Chunk of values (or Nothing if no values yet)
  3. Can perform Om effects (context access, error handling, etc.)

This design enables:

Documentation

See Also

Credits

Inspired by ZIO ZStream from the Scala ZIO ecosystem.