git.iko.soy/rowtype-yoga/purescript-yoga-om-strom
on main help sign in
20 files changed+5003−330
since splitsnapshots
unifiedsplit
README.md+1 _excluded/Examples.purs+523 _excluded/FileStream.purs+37 _excluded/Strom.prop.purs+429 _excluded/Strom.test.purs+669 src/Test/Main.purs → _excluded/Test/Main.purs+4 spago.lock+357−10 spago.yaml+6−6 src/Benchmark.purs+1−2 src/Yoga/Om/Strom.purs+264−302 src/Yoga/Om/Strom.purs.backup+1809 src/Yoga/Om/Strom.test.purs+3−3 src/Yoga/Om/Strom/Examples.purs+3−3 src/Yoga/Om/Strom/FileStream.js+17 src/Yoga/Om/Strom/WebStream.js+176 src/Yoga/Om/Strom/WebStream.purs+360 test/Test/Main.purs+12 test/Test/Strom/Spec.purs+3−4 test/Test/WebStream.js+106 test/Test/WebStream.purs+223
README.md+1
@@ -441,3 +441,4 @@ This design enables:
441441## Credits
442442
443443Inspired by [ZIO ZStream](https://zio.dev/reference/stream/zstream) from the Scala ZIO ecosystem.
444+
_excluded/Examples.pursnew file+523
@@ -0,0 +1,523 @@
1+module Yoga.Om.Strom.Examples where
2+
3+import Prelude
4+
5+import Control.Alt ((<|>))
6+import Data.Array as Array
7+import Data.Maybe (Maybe(..))
8+import Data.Tuple (Tuple(..))
9+import Effect.Aff (Aff)
10+import Effect.Class.Console as Console
11+import Data.Time.Duration (Milliseconds(..))
12+import Yoga.Om (Om)
13+import Yoga.Om as Om
14+import Yoga.Om.Strom as Strom
15+
16+--------------------------------------------------------------------------------
17+-- Example 1: Basic Stream Operations
18+--------------------------------------------------------------------------------
19+
20+-- | Simple range and mapping
21+example1 :: Om {} () (Array Int)
22+example1 = do
23+ Strom.rangeStrom 1 10
24+ # Strom.mapStrom (_ * 2)
25+ # Strom.filterStrom (_ > 5)
26+ # Strom.runCollect
27+
28+-- Expected output: [6, 8, 10, 12, 14, 16, 18]
29+
30+--------------------------------------------------------------------------------
31+-- Example 2: Effectful Operations
32+--------------------------------------------------------------------------------
33+
34+type LoggerContext = { logger :: String -> Aff Unit }
35+
36+-- | Stream with effectful mapping and tapping
37+example2 :: Om LoggerContext () Unit
38+example2 = do
39+ Strom.rangeStrom 1 5
40+ # Strom.tapMStrom (\n -> do
41+ { logger } <- Om.ask
42+ Om.fromAff $ logger $ "Processing: " <> show n
43+ )
44+ # Strom.mapMStrom (\n -> do
45+ Om.delay (Milliseconds 100.0)
46+ pure (n * n)
47+ )
48+ # Strom.traverseStrom_ (\n -> do
49+ { logger } <- Om.ask
50+ Om.fromAff $ logger $ "Result: " <> show n
51+ )
52+
53+-- Expected output:
54+-- Processing: 1
55+-- Processing: 2
56+-- ...
57+-- Result: 1
58+-- Result: 4
59+-- Result: 9
60+-- Result: 16
61+-- Result: 25
62+
63+--------------------------------------------------------------------------------
64+-- Example 3: Parallel Processing
65+--------------------------------------------------------------------------------
66+
67+-- | Process items in parallel (simulating API calls)
68+fetchUser :: Int -> Om {} () String
69+fetchUser id = do
70+ Om.delay (Milliseconds 100.0)
71+ pure $ "User #" <> show id
72+
73+example3 :: Om {} () (Array String)
74+example3 = do
75+ Strom.rangeStrom 1 20
76+ # Strom.mapMStrom fetchUser -- TODO: mapParallel not yet implemented
77+ # Strom.runCollect
78+
79+--------------------------------------------------------------------------------
80+-- Example 4: Stateful Transformations
81+--------------------------------------------------------------------------------
82+
83+-- | Running total using scan
84+example4 :: Om {} () (Array Int)
85+example4 = do
86+ Strom.fromArray [1, 2, 3, 4, 5]
87+ # Strom.scanStrom (\acc n -> acc + n) 0
88+ # Strom.runCollect
89+
90+-- Expected output: [1, 3, 6, 10, 15]
91+
92+-- | Stateful map with accumulator
93+example4b :: Om {} () (Array String)
94+example4b = do
95+ Strom.fromArray ["a", "b", "c", "d"]
96+ # Strom.mapAccumStrom (\count item -> Tuple (count + 1) (show count <> ": " <> item)) 1
97+ # Strom.runCollect
98+
99+-- Expected output: ["1: a", "2: b", "3: c", "4: d"]
100+
101+--------------------------------------------------------------------------------
102+-- Example 5: Stream Combination
103+--------------------------------------------------------------------------------
104+
105+-- | Zip two streams
106+example5 :: Om {} () (Array (Tuple Int String))
107+example5 = do
108+ let numbers = Strom.rangeStrom 1 5
109+ let letters = Strom.fromArray ["a", "b", "c", "d", "e"]
110+ -- TODO: zipStrom not yet implemented
111+ numbers # Strom.mapStrom (\n -> Tuple n "stub") # Strom.runCollect
112+
113+-- Expected output: [(1, "a"), (2, "b"), (3, "c"), (4, "d"), (5, "e")]
114+
115+-- | Interleave two streams deterministically
116+example5b :: Om {} () (Array Int)
117+example5b = do
118+ let evens = Strom.fromArray [2, 4, 6, 8]
119+ let odds = Strom.fromArray [1, 3, 5, 7]
120+ -- TODO: interleave not yet implemented
121+ (odds <> evens) # Strom.runCollect
122+
123+-- Expected output: [1, 2, 3, 4, 5, 6, 7, 8]
124+
125+-- | Merge streams (non-deterministic, race-based)
126+example5c :: Om {} () (Array Int)
127+example5c = do
128+ let stream1 = Strom.fromArray [1, 2, 3]
129+ let stream2 = Strom.fromArray [4, 5, 6]
130+ -- TODO: merge not yet implemented
131+ (stream1 <|> stream2) # Strom.runCollect
132+
133+-- Output: non-deterministic, could be [1, 2, 3, 4, 5, 6] or [4, 5, 6, 1, 2, 3] etc
134+
135+--------------------------------------------------------------------------------
136+-- Example 6: Infinite Streams
137+--------------------------------------------------------------------------------
138+
139+-- | Take from an infinite stream
140+example6 :: Om {} () (Array Int)
141+example6 = do
142+ Strom.iterateStrom (_ + 1) 0
143+ # Strom.takeStrom 10
144+ # Strom.runCollect
145+
146+-- Expected output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
147+
148+-- | Repeat with condition
149+example6b :: Om {} () (Array Int)
150+example6b = do
151+ Strom.repeatStrom 42
152+ # Strom.takeWhileStrom (_ == 42) -- Will take all
153+ # Strom.takeStrom 5 -- But we limit to 5
154+ # Strom.runCollect
155+
156+-- Expected output: [42, 42, 42, 42, 42]
157+
158+--------------------------------------------------------------------------------
159+-- Example 7: Grouping and Chunking
160+--------------------------------------------------------------------------------
161+
162+-- | Group elements into chunks
163+example7 :: Om {} () (Array (Array Int))
164+example7 = do
165+ Strom.rangeStrom 1 11
166+ # Strom.mapStrom (\x -> [x]) -- TODO: grouped not yet implemented
167+ # Strom.runCollect
168+
169+-- Expected output: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
170+
171+-- | Process in batches
172+example7b :: Om LoggerContext () Unit
173+example7b = do
174+ Strom.rangeStrom 1 100
175+ # Strom.mapStrom (\x -> [x]) -- TODO: chunked not yet implemented
176+ # Strom.traverseStrom_ (\batch -> do
177+ { logger } <- Om.ask
178+ Om.fromAff $ logger $ "Processing batch of " <> show (Array.length batch) <> " items"
179+ )
180+
181+--------------------------------------------------------------------------------
182+-- Example 8: Deduplication
183+--------------------------------------------------------------------------------
184+
185+-- | Remove consecutive duplicates
186+example8 :: Om {} () (Array Int)
187+example8 = do
188+ Strom.fromArray [1, 1, 2, 2, 2, 3, 1, 1, 4]
189+ # Strom.changesStrom
190+ # Strom.runCollect
191+
192+-- Expected output: [1, 2, 3, 1, 4]
193+
194+--------------------------------------------------------------------------------
195+-- Example 9: Complex Pipeline (Real-world-ish)
196+--------------------------------------------------------------------------------
197+
198+type User = { id :: Int, name :: String, active :: Boolean }
199+
200+-- | Fetch users from API, process active ones, and save
201+example9 :: Om LoggerContext () Unit
202+example9 = do
203+ let users =
204+ [ { id: 1, name: "Alice", active: true }
205+ , { id: 2, name: "Bob", active: false }
206+ , { id: 3, name: "Charlie", active: true }
207+ , { id: 4, name: "Dave", active: true }
208+ , { id: 5, name: "Eve", active: false }
209+ ]
210+
211+ Strom.fromArray users
212+ # Strom.filterStrom _.active
213+ # Strom.tapMStrom (\user -> do
214+ { logger } <- Om.ask
215+ Om.fromAff $ logger $ "Processing active user: " <> user.name
216+ )
217+ # Strom.mapMStrom (\user -> do
218+ -- Simulate enriching user data
219+ Om.delay (Milliseconds 50.0)
220+ pure $ user.name <> " (ID: " <> show user.id <> ")"
221+ )
222+ # Strom.mapStrom (\x -> [x]) -- TODO: grouped not yet implemented
223+ # Strom.traverseStrom_ (\batch -> do
224+ { logger } <- Om.ask
225+ Om.fromAff $ logger $ "Saving batch: " <> show batch
226+ )
227+
228+-- Expected output:
229+-- Processing active user: Alice
230+-- Processing active user: Charlie
231+-- Processing active user: Dave
232+-- Saving batch: ["Alice (ID: 1)", "Charlie (ID: 3)"]
233+-- Saving batch: ["Dave (ID: 4)"]
234+
235+--------------------------------------------------------------------------------
236+-- Example 10: Unfold Pattern
237+--------------------------------------------------------------------------------
238+
239+-- | Generate Fibonacci sequence
240+fibonacciStream :: forall ctx err. Strom.Strom ctx err Int
241+fibonacciStream =
242+ Strom.unfoldStrom (\(Tuple a b) -> Just (Tuple a (Tuple b (a + b)))) (Tuple 0 1)
243+
244+example10 :: Om {} () (Array Int)
245+example10 = do
246+ fibonacciStream
247+ # Strom.takeStrom 10
248+ # Strom.runCollect
249+
250+-- Expected output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
251+
252+-- | Unfold with effects (e.g., paginated API)
253+type Page = { items :: Array String, nextToken :: Maybe String }
254+
255+fetchPage :: String -> Om {} () Page
256+fetchPage token = do
257+ Om.delay (Milliseconds 100.0)
258+ -- Simulate API call
259+ if token == "start"
260+ then pure { items: ["item1", "item2"], nextToken: Just "page2" }
261+ else if token == "page2"
262+ then pure { items: ["item3", "item4"], nextToken: Just "page3" }
263+ else if token == "page3"
264+ then pure { items: ["item5"], nextToken: Nothing }
265+ else pure { items: [], nextToken: Nothing }
266+
267+example10b :: Om {} () (Array String)
268+example10b = do
269+ Strom.unfoldOmStrom (\token -> do
270+ page <- fetchPage token
271+ case page.nextToken of
272+ Nothing -> pure Nothing
273+ Just next -> pure $ Just $ Tuple page.items next
274+ ) "start"
275+ >>= Strom.fromArray
276+ # Strom.runCollect
277+
278+-- Expected output: ["item1", "item2", "item3", "item4", "item5"]
279+
280+--------------------------------------------------------------------------------
281+-- Example 11: Racing Streams
282+--------------------------------------------------------------------------------
283+
284+-- | Race multiple data sources
285+example11 :: Om {} () (Array Int)
286+example11 = do
287+ let source1 = Strom.fromOm do
288+ Om.delay (Milliseconds 100.0)
289+ pure 1
290+
291+ source2 = Strom.fromOm do
292+ Om.delay (Milliseconds 50.0) -- This will win
293+ pure 2
294+
295+ source3 = Strom.fromOm do
296+ Om.delay (Milliseconds 200.0)
297+ pure 3
298+
299+ -- TODO: race not yet implemented
300+ source1 # Strom.runCollect
301+
302+-- Expected output: [2] (the fastest source)
303+
304+--------------------------------------------------------------------------------
305+-- Example 12: Partition Stream
306+--------------------------------------------------------------------------------
307+
308+-- | Split stream into two based on predicate
309+example12 :: Om {} () { evens :: Array Int, odds :: Array Int }
310+example12 = do
311+ let stream = Strom.rangeStrom 1 11
312+ -- TODO: partition not yet implemented
313+ let evens = stream # Strom.filterStrom (\n -> n `mod` 2 == 0)
314+ let odds = stream # Strom.filterStrom (\n -> n `mod` 2 /= 0)
315+
316+ evensResult <- Strom.runCollect evens
317+ oddsResult <- Strom.runCollect odds
318+
319+ pure { evens: evensResult, odds: oddsResult }
320+
321+-- Expected output: { evens: [2, 4, 6, 8, 10], odds: [1, 3, 5, 7, 9] }
322+
323+--------------------------------------------------------------------------------
324+-- Example 13: Collect Pattern (filter + map in one)
325+--------------------------------------------------------------------------------
326+
327+-- | Extract and transform in one pass
328+parseNumbers :: String -> Maybe Int
329+parseNumbers "one" = Just 1
330+parseNumbers "two" = Just 2
331+parseNumbers "three" = Just 3
332+parseNumbers _ = Nothing
333+
334+example13 :: Om {} () (Array Int)
335+example13 = do
336+ Strom.fromArray ["one", "invalid", "two", "nope", "three"]
337+ # Strom.collectStrom parseNumbers
338+ # Strom.runCollect
339+
340+-- Expected output: [1, 2, 3]
341+
342+-- | Collect with Om effect
343+example13b :: Om {} () (Array Int)
344+example13b = do
345+ Strom.fromArray ["1", "not a number", "2", "3", "invalid"]
346+ # Strom.collectMStrom (\str -> do
347+ Om.delay (Milliseconds 10.0)
348+ pure $ case str of
349+ "1" -> Just 1
350+ "2" -> Just 2
351+ "3" -> Just 3
352+ _ -> Nothing
353+ )
354+ # Strom.runCollect
355+
356+-- Expected output: [1, 2, 3]
357+
358+--------------------------------------------------------------------------------
359+-- Example 14: Drop Operations
360+--------------------------------------------------------------------------------
361+
362+-- | Skip elements
363+example14 :: Om {} () (Array Int)
364+example14 = do
365+ Strom.rangeStrom 1 11
366+ # Strom.dropStrom 5
367+ # Strom.runCollect
368+
369+-- Expected output: [6, 7, 8, 9, 10]
370+
371+-- | Drop while condition holds
372+example14b :: Om {} () (Array Int)
373+example14b = do
374+ Strom.rangeStrom 1 11
375+ # Strom.dropWhileStrom (_ < 5)
376+ # Strom.runCollect
377+
378+-- Expected output: [5, 6, 7, 8, 9, 10]
379+
380+--------------------------------------------------------------------------------
381+-- Example 15: Subscription Pattern (Long-running streams)
382+--------------------------------------------------------------------------------
383+
384+-- | Subscribe to a stream and cancel later
385+example15 :: Om LoggerContext () Unit
386+example15 = do
387+ let eventStream =
388+ Strom.iterateStrom (_ + 1) 0
389+ # Strom.mapMStrom (\n -> do
390+ Om.delay (Milliseconds 500.0)
391+ pure n
392+ )
393+
394+ -- TODO: subscribe not yet implemented
395+ eventStream
396+ # Strom.takeStrom 5
397+ # Strom.traverseStrom_ (\n -> do
398+ { logger } <- Om.ask
399+ Om.fromAff $ logger $ "Received: " <> show n
400+ )
401+
402+-- Expected output:
403+-- Received: 0
404+-- Received: 1
405+-- Received: 2
406+-- Received: 3
407+-- Received: 4
408+-- Cancelling subscription...
409+
410+--------------------------------------------------------------------------------
411+-- Example 16: FlatMap for Nested Streams
412+--------------------------------------------------------------------------------
413+
414+-- | Flatten nested data structures
415+type Department = { name :: String, employees :: Array String }
416+
417+example16 :: Om {} () (Array String)
418+example16 = do
419+ let departments =
420+ [ { name: "Engineering", employees: ["Alice", "Bob"] }
421+ , { name: "Sales", employees: ["Charlie"] }
422+ , { name: "Marketing", employees: ["Dave", "Eve", "Frank"] }
423+ ]
424+
425+ Strom.fromArray departments
426+ >>= (\dept -> Strom.fromArray dept.employees)
427+ # Strom.runCollect
428+
429+-- Expected output: ["Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"]
430+
431+--------------------------------------------------------------------------------
432+-- Example 17: Error Handling
433+--------------------------------------------------------------------------------
434+
435+data AppError = NetworkError | ValidationError | TimeoutError
436+
437+-- | Catch errors and provide fallback
438+example17 :: Om {} (networkError :: String) (Array Int)
439+example17 = do
440+ let riskyStream = Strom.rangeStrom 1 5
441+ # Strom.mapMStrom (\n -> do
442+ if n == 3
443+ then Om.throw { networkError: "Connection failed!" }
444+ else pure (n * 2)
445+ )
446+
447+ -- TODO: catchAll not yet implemented
448+ riskyStream # Strom.runCollect
449+
450+-- Expected output: [2, 4, 999, 8, 10]
451+
452+-- | Provide alternative stream on failure
453+example17b :: Om {} (networkError :: String) (Array Int)
454+example17b = do
455+ let primaryStream = Strom.fromOm $ Om.throw { networkError: "Primary failed" }
456+ let fallbackStream = Strom.fromArray [1, 2, 3]
457+
458+ -- TODO: orElse not yet implemented, using Alt instance
459+ (primaryStream <|> fallbackStream) # Strom.runCollect
460+
461+-- Expected output: [1, 2, 3]
462+
463+--------------------------------------------------------------------------------
464+-- Example 18: Real-world: Event Processing Pipeline
465+--------------------------------------------------------------------------------
466+
467+type Event = { timestamp :: Int, userId :: String, action :: String }
468+
469+-- | Complete event processing pipeline
470+eventProcessingPipeline :: Om LoggerContext () Unit
471+eventProcessingPipeline = do
472+ let events =
473+ [ { timestamp: 1000, userId: "u1", action: "login" }
474+ , { timestamp: 1001, userId: "u1", action: "view" }
475+ , { timestamp: 1002, userId: "u2", action: "login" }
476+ , { timestamp: 1003, userId: "u1", action: "view" }
477+ , { timestamp: 1004, userId: "u1", action: "view" }
478+ , { timestamp: 1005, userId: "u2", action: "purchase" }
479+ ]
480+
481+ Strom.fromArray events
482+ # Strom.filterStrom (\e -> e.action /= "login") -- Filter out logins
483+ # Strom.changesStrom -- Remove consecutive duplicates
484+ # Strom.mapAccumStrom -- Add sequence numbers
485+ (\seq event -> Tuple (seq + 1) { seq, event })
486+ 0
487+ # Strom.tapMStrom (\{ seq, event } -> do -- Log each event
488+ { logger } <- Om.ask
489+ Om.fromAff $ logger $
490+ "Event #" <> show seq <> ": "
491+ <> event.userId <> " - " <> event.action
492+ )
493+ # Strom.mapStrom (\x -> [x]) -- TODO: grouped not yet implemented
494+ # Strom.traverseStrom_ (\batch -> do -- Process batches
495+ { logger } <- Om.ask
496+ Om.fromAff $ logger $ "Processed batch of " <> show (Array.length batch)
497+ )
498+
499+-- Expected output:
500+-- Event #1: u1 - view
501+-- Event #2: u2 - purchase
502+-- Processed batch of 2
503+
504+--------------------------------------------------------------------------------
505+-- Helper to run examples
506+--------------------------------------------------------------------------------
507+
508+runExample :: forall a. Show a => Om {} () a -> Aff Unit
509+runExample om = do
510+ result <- Om.runOm {} { exception: \err -> do
511+ Console.log $ "Error: " <> show err
512+ pure unit
513+ } (om >>= \a -> Om.fromAff $ Console.log $ show a)
514+ pure result
515+
516+runExampleWithLogger :: forall a. Show a => Om LoggerContext () a -> Aff Unit
517+runExampleWithLogger om = do
518+ let ctx = { logger: \msg -> Console.log msg }
519+ result <- Om.runOm ctx { exception: \err -> do
520+ Console.log $ "Error: " <> show err
521+ pure unit
522+ } (om >>= \a -> Om.fromAff $ Console.log $ show a)
523+ pure result
_excluded/FileStream.pursnew file+37
@@ -0,0 +1,37 @@
1+module Yoga.Om.Strom.FileStream where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.String as String
7+import Effect (Effect)
8+import Effect.Class (liftEffect)
9+import Effect.Random (randomInt)
10+import Yoga.Om (Om)
11+import Yoga.Om.Strom as Strom
12+
13+foreign import _writeFile :: String -> String -> Effect Unit
14+foreign import _appendFile :: String -> String -> Effect Unit
15+foreign import _readFile :: String -> Effect String
16+foreign import _unlinkFile :: String -> Effect Unit
17+
18+-- | Infinite stream of random ints in [lo, hi]
19+randomInts :: forall ctx err. Int -> Int -> Strom.Strom ctx err Int
20+randomInts lo hi =
21+ Strom.repeatOmStromInfinite (liftEffect $ randomInt lo hi)
22+
23+-- | Stream n random integers to a file at the given path, one per line.
24+streamRandomToFile :: String -> Int -> Om {} () Unit
25+streamRandomToFile path n = do
26+ liftEffect $ _writeFile path ""
27+ randomInts 0 999999
28+ # Strom.takeStrom n
29+ # Strom.mapStrom (\i -> show i <> "\n")
30+ # Strom.traverseStrom_ (\line -> liftEffect $ _appendFile path line)
31+
32+-- | Read a file and count non-empty lines
33+countLines :: String -> Effect Int
34+countLines path = do
35+ content <- _readFile path
36+ let lines = Array.filter (_ /= "") $ String.split (String.Pattern "\n") content
37+ pure $ Array.length lines
_excluded/Strom.prop.pursnew file+429
@@ -0,0 +1,429 @@
1+module Yoga.Om.Strom.PropTest where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Maybe (Maybe(..))
7+import Data.String (Pattern(..), split) as String
8+import Data.Traversable (for_)
9+import Data.Tuple (Tuple(..))
10+import Data.Time.Duration (Milliseconds(..))
11+import Effect.Aff (Aff, delay)
12+import Effect.Class (liftEffect)
13+import Effect.Exception (throwException)
14+import Effect.Ref as Ref
15+import Test.QuickCheck.Gen (Gen, chooseInt, randomSample', vectorOf)
16+import Test.Spec (Spec, describe, it)
17+import Test.Spec.Assertions (shouldEqual)
18+import Yoga.Om (Om)
19+import Yoga.Om as Om
20+import Yoga.Om.Strom as Strom
21+import Yoga.Om.Strom.FileStream as FileStream
22+
23+-- Helper to run Om in tests
24+runOm :: forall a. Om {} () a -> Aff a
25+runOm om = Om.runOm {} { exception: \err -> liftEffect (throwException err) } om
26+
27+-- | Small arrays of ints (0–50 elements)
28+genSmallArray :: Gen (Array Int)
29+genSmallArray = do
30+ len <- chooseInt 0 50
31+ vectorOf len (chooseInt (-1000) 1000)
32+
33+-- | Non-negative int for take/drop/grouped args
34+genSmallNat :: Gen Int
35+genSmallNat = chooseInt 0 100
36+
37+-- | Positive int (>0) for grouped size
38+genPositiveNat :: Gen Int
39+genPositiveNat = chooseInt 1 50
40+
41+-- | Multi-chunk arrays (1000–3000 elements) to force chunk boundaries
42+genMultiChunkArray :: Gen (Array Int)
43+genMultiChunkArray = do
44+ len <- chooseInt 1000 3000
45+ vectorOf len (chooseInt (-100) 100)
46+
47+-- | Even predicate (can't generate arbitrary predicates)
48+isEven :: Int -> Boolean
49+isEven n = n `mod` 2 == 0
50+
51+spec :: Spec Unit
52+spec = do
53+ describe "Strom Properties" do
54+
55+ describe "Roundtrip / Identity" do
56+
57+ it "fromArray >>> runCollect ≡ identity" do
58+ samples <- liftEffect $ randomSample' 50 genSmallArray
59+ for_ samples \arr -> do
60+ result <- runOm $ Strom.fromArray arr # Strom.runCollect
61+ result `shouldEqual` arr
62+
63+ it "mapStrom identity ≡ identity" do
64+ samples <- liftEffect $ randomSample' 50 genSmallArray
65+ for_ samples \arr -> do
66+ result <- runOm $ Strom.fromArray arr # Strom.mapStrom identity # Strom.runCollect
67+ result `shouldEqual` arr
68+
69+ describe "Consistency with Array equivalents" do
70+
71+ it "takeStrom n ≡ Array.take n" do
72+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genSmallNat <*> genSmallArray)
73+ for_ samples \(Tuple n arr) -> do
74+ result <- runOm $ Strom.fromArray arr # Strom.takeStrom n # Strom.runCollect
75+ result `shouldEqual` Array.take n arr
76+
77+ it "dropStrom n ≡ Array.drop n" do
78+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genSmallNat <*> genSmallArray)
79+ for_ samples \(Tuple n arr) -> do
80+ result <- runOm $ Strom.fromArray arr # Strom.dropStrom n # Strom.runCollect
81+ result `shouldEqual` Array.drop n arr
82+
83+ it "filterStrom isEven ≡ Array.filter isEven" do
84+ samples <- liftEffect $ randomSample' 50 genSmallArray
85+ for_ samples \arr -> do
86+ result <- runOm $ Strom.fromArray arr # Strom.filterStrom isEven # Strom.runCollect
87+ result `shouldEqual` Array.filter isEven arr
88+
89+ it "collectStrom f ≡ Array.mapMaybe f" do
90+ let f n = if isEven n then Just (n * 10) else Nothing
91+ samples <- liftEffect $ randomSample' 50 genSmallArray
92+ for_ samples \arr -> do
93+ result <- runOm $ Strom.fromArray arr # Strom.collectStrom f # Strom.runCollect
94+ result `shouldEqual` Array.mapMaybe f arr
95+
96+ it "scanStrom (+) 0 ≡ Array.scanl (+) 0" do
97+ samples <- liftEffect $ randomSample' 50 genSmallArray
98+ for_ samples \arr -> do
99+ result <- runOm $ Strom.fromArray arr # Strom.scanStrom (+) 0 # Strom.runCollect
100+ result `shouldEqual` Array.scanl (+) 0 arr
101+
102+ describe "Composition laws" do
103+
104+ it "take n (take m s) ≡ take (min n m) s" do
105+ samples <- liftEffect $ randomSample' 50 do
106+ n <- genSmallNat
107+ m <- genSmallNat
108+ arr <- genSmallArray
109+ pure { n, m, arr }
110+ for_ samples \{ n, m, arr } -> do
111+ result1 <- runOm $ Strom.fromArray arr # Strom.takeStrom m # Strom.takeStrom n # Strom.runCollect
112+ result2 <- runOm $ Strom.fromArray arr # Strom.takeStrom (min n m) # Strom.runCollect
113+ result1 `shouldEqual` result2
114+
115+ it "drop n (drop m s) ≡ drop (n + m) s" do
116+ samples <- liftEffect $ randomSample' 50 do
117+ n <- genSmallNat
118+ m <- genSmallNat
119+ arr <- genSmallArray
120+ pure { n, m, arr }
121+ for_ samples \{ n, m, arr } -> do
122+ result1 <- runOm $ Strom.fromArray arr # Strom.dropStrom m # Strom.dropStrom n # Strom.runCollect
123+ result2 <- runOm $ Strom.fromArray arr # Strom.dropStrom (n + m) # Strom.runCollect
124+ result1 `shouldEqual` result2
125+
126+ it "take n <> drop n ≡ identity (partition property)" do
127+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genSmallNat <*> genSmallArray)
128+ for_ samples \(Tuple n arr) -> do
129+ taken <- runOm $ Strom.fromArray arr # Strom.takeStrom n # Strom.runCollect
130+ dropped <- runOm $ Strom.fromArray arr # Strom.dropStrom n # Strom.runCollect
131+ (taken <> dropped) `shouldEqual` arr
132+
133+ describe "Monoid laws" do
134+
135+ it "empty <> s ≡ s (left identity)" do
136+ samples <- liftEffect $ randomSample' 50 genSmallArray
137+ for_ samples \arr -> do
138+ result <- runOm $ (Strom.empty <> Strom.fromArray arr) # Strom.runCollect
139+ result `shouldEqual` arr
140+
141+ it "s <> empty ≡ s (right identity)" do
142+ samples <- liftEffect $ randomSample' 50 genSmallArray
143+ for_ samples \arr -> do
144+ result <- runOm $ (Strom.fromArray arr <> Strom.empty) # Strom.runCollect
145+ result `shouldEqual` arr
146+
147+ it "(s1 <> s2) <> s3 ≡ s1 <> (s2 <> s3) (associativity)" do
148+ samples <- liftEffect $ randomSample' 30 do
149+ a <- genSmallArray
150+ b <- genSmallArray
151+ c <- genSmallArray
152+ pure { a, b, c }
153+ for_ samples \{ a, b, c } -> do
154+ let s1 = Strom.fromArray a
155+ let s2 = Strom.fromArray b
156+ let s3 = Strom.fromArray c
157+ result1 <- runOm $ ((s1 <> s2) <> s3) # Strom.runCollect
158+ result2 <- runOm $ (s1 <> (s2 <> s3)) # Strom.runCollect
159+ result1 `shouldEqual` result2
160+
161+ describe "Combining" do
162+
163+ it "zip length ≡ min of input lengths" do
164+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genSmallArray <*> genSmallArray)
165+ for_ samples \(Tuple a b) -> do
166+ result <- runOm $ Strom.zipStrom (Strom.fromArray a) (Strom.fromArray b) # Strom.runCollect
167+ Array.length result `shouldEqual` min (Array.length a) (Array.length b)
168+
169+ it "zip produces correct pairs" do
170+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genSmallArray <*> genSmallArray)
171+ for_ samples \(Tuple a b) -> do
172+ result <- runOm $ Strom.zipStrom (Strom.fromArray a) (Strom.fromArray b) # Strom.runCollect
173+ let expected = Array.zipWith Tuple a b
174+ result `shouldEqual` expected
175+
176+ it "intersperse element count = max 0 (2 * length - 1)" do
177+ samples <- liftEffect $ randomSample' 50 genSmallArray
178+ for_ samples \arr -> do
179+ result <- runOm $ Strom.fromArray arr # Strom.intersperse 0 # Strom.runCollect
180+ let expectedLen = max 0 (2 * Array.length arr - 1)
181+ Array.length result `shouldEqual` expectedLen
182+
183+ it "grouped n then flatten ≡ identity" do
184+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genPositiveNat <*> genSmallArray)
185+ for_ samples \(Tuple n arr) -> do
186+ result <- runOm $ Strom.fromArray arr # Strom.groupedStrom n # Strom.mapStrom identity # Strom.runCollect
187+ let flattened = Array.concat result
188+ flattened `shouldEqual` arr
189+
190+ it "fromArray (a <> b) ≡ fromArray a <> fromArray b (append homomorphism)" do
191+ samples <- liftEffect $ randomSample' 50 (Tuple <$> genSmallArray <*> genSmallArray)
192+ for_ samples \(Tuple a b) -> do
193+ result1 <- runOm $ Strom.fromArray (a <> b) # Strom.runCollect
194+ result2 <- runOm $ (Strom.fromArray a <> Strom.fromArray b) # Strom.runCollect
195+ result1 `shouldEqual` result2
196+
197+ describe "Multi-chunk stress tests" do
198+
199+ it "fromArray >>> runCollect roundtrip (multi-chunk)" do
200+ samples <- liftEffect $ randomSample' 5 genMultiChunkArray
201+ for_ samples \arr -> do
202+ result <- runOm $ Strom.fromArray arr # Strom.runCollect
203+ result `shouldEqual` arr
204+
205+ it "takeStrom n ≡ Array.take n (multi-chunk via rangeStrom)" do
206+ samples <- liftEffect $ randomSample' 20 (chooseInt 0 3000)
207+ for_ samples \n -> do
208+ let arr = Array.range 0 2999
209+ result <- runOm $ Strom.rangeStrom 0 3000 # Strom.takeStrom n # Strom.runCollect
210+ result `shouldEqual` Array.take n arr
211+
212+ it "dropStrom n ≡ Array.drop n (multi-chunk via rangeStrom)" do
213+ samples <- liftEffect $ randomSample' 20 (chooseInt 0 3000)
214+ for_ samples \n -> do
215+ let arr = Array.range 0 2999
216+ result <- runOm $ Strom.rangeStrom 0 3000 # Strom.dropStrom n # Strom.runCollect
217+ result `shouldEqual` Array.drop n arr
218+
219+ it "filterStrom isEven (multi-chunk via rangeStrom)" do
220+ result <- runOm $ Strom.rangeStrom 0 3000 # Strom.filterStrom isEven # Strom.runCollect
221+ let expected = Array.filter isEven (Array.range 0 2999)
222+ result `shouldEqual` expected
223+
224+ it "scanStrom (+) 0 (multi-chunk via rangeStrom)" do
225+ result <- runOm $ Strom.rangeStrom 1 2001 # Strom.scanStrom (+) 0 # Strom.runCollect
226+ let expected = Array.scanl (+) 0 (Array.range 1 2000)
227+ result `shouldEqual` expected
228+
229+ it "take n <> drop n ≡ identity (multi-chunk)" do
230+ samples <- liftEffect $ randomSample' 10 (chooseInt 0 3000)
231+ for_ samples \n -> do
232+ taken <- runOm $ Strom.rangeStrom 0 3000 # Strom.takeStrom n # Strom.runCollect
233+ dropped <- runOm $ Strom.rangeStrom 0 3000 # Strom.dropStrom n # Strom.runCollect
234+ (taken <> dropped) `shouldEqual` Array.range 0 2999
235+
236+ it "grouped n then flatten ≡ identity (multi-chunk)" do
237+ samples <- liftEffect $ randomSample' 10 genPositiveNat
238+ for_ samples \n -> do
239+ result <- runOm $ Strom.rangeStrom 0 2500 # Strom.groupedStrom n # Strom.runCollect
240+ let flattened = Array.concat result
241+ flattened `shouldEqual` Array.range 0 2499
242+
243+ it "zip with multi-chunk streams" do
244+ result <- runOm $
245+ Strom.zipWithStrom (+) (Strom.rangeStrom 0 2000) (Strom.rangeStrom 0 2000)
246+ # Strom.runCollect
247+ let expected = map (\i -> i + i) (Array.range 0 1999)
248+ result `shouldEqual` expected
249+
250+ it "fromArray (a <> b) ≡ fromArray a <> fromArray b (multi-chunk)" do
251+ samples <- liftEffect $ randomSample' 5 (Tuple <$> genMultiChunkArray <*> genMultiChunkArray)
252+ for_ samples \(Tuple a b) -> do
253+ result1 <- runOm $ Strom.fromArray (a <> b) # Strom.runCollect
254+ result2 <- runOm $ (Strom.fromArray a <> Strom.fromArray b) # Strom.runCollect
255+ result1 `shouldEqual` result2
256+
257+ it "intersperse element count (multi-chunk via rangeStrom)" do
258+ result <- runOm $ Strom.rangeStrom 0 2000 # Strom.intersperse 0 # Strom.runCollect
259+ Array.length result `shouldEqual` (2 * 2000 - 1)
260+
261+ it "mapStrom identity (multi-chunk via rangeStrom)" do
262+ result <- runOm $ Strom.rangeStrom 0 2500 # Strom.mapStrom identity # Strom.runCollect
263+ result `shouldEqual` Array.range 0 2499
264+
265+ it "monoid associativity (multi-chunk)" do
266+ let
267+ s1 = Strom.rangeStrom 0 1500
268+ s2 = Strom.rangeStrom 1500 3000
269+ s3 = Strom.rangeStrom 3000 4500
270+ result1 <- runOm $ ((s1 <> s2) <> s3) # Strom.runCollect
271+ result2 <- runOm $ (s1 <> (s2 <> s3)) # Strom.runCollect
272+ result1 `shouldEqual` result2
273+
274+ describe "Apply instance (monad law: ap ≡ <*>)" do
275+
276+ -- For a lawful Monad, `fs <*> xs` must equal `fs >>= \f -> xs >>= \x -> pure (f x)`
277+ -- The Apply instance has a bug in the (Loop, Loop) case: it only pairs
278+ -- corresponding chunks instead of doing the full cartesian product.
279+
280+ it "apply matches ap for single-chunk streams" do
281+ -- Single-chunk: both streams produce Done, so Apply works correctly
282+ let fs = Strom.fromArray [(_ + 10), (_ * 2)]
283+ let xs = Strom.fromArray [1, 2, 3]
284+ applyResult <- runOm $ (fs <*> xs) # Strom.runCollect
285+ let ap' mf ma = mf >>= \f -> ma >>= \a -> pure (f a)
286+ apResult <- runOm $ ap' (Strom.fromArray [(_ + 10), (_ * 2)]) (Strom.fromArray [1, 2, 3]) # Strom.runCollect
287+ applyResult `shouldEqual` apResult
288+
289+ it "apply matches ap for multi-chunk streams (BUG EXPECTED)" do
290+ -- Multi-chunk: both streams are built via <>, producing Loop steps.
291+ -- The Apply instance pairs corresponding chunks instead of doing cartesian product.
292+ -- fs = [+10] then [*2], xs = [1,2] then [3,4]
293+ -- Correct (ap): [11,12,13,14,2,4,6,8]
294+ -- Buggy (<*>): [11,12,6,8] (only pairs chunk1×chunk1 and chunk2×chunk2)
295+ let fs = Strom.fromArray [(_ + 10)] <> Strom.fromArray [(_ * 2)]
296+ let xs = Strom.fromArray [1, 2] <> Strom.fromArray [3, 4]
297+ applyResult <- runOm $ (fs <*> xs) # Strom.runCollect
298+ let ap' mf ma = mf >>= \f -> ma >>= \a -> pure (f a)
299+ apResult <- runOm $ ap' (Strom.fromArray [(_ + 10)] <> Strom.fromArray [(_ * 2)]) (Strom.fromArray [1, 2] <> Strom.fromArray [3, 4]) # Strom.runCollect
300+ applyResult `shouldEqual` apResult
301+
302+ it "apply matches ap with rangeStrom (multi-chunk, BUG EXPECTED)" do
303+ -- rangeStrom 1 3 = single chunk [1,2], rangeStrom 1 2001 = two 1000-element chunks
304+ -- Use small functions to keep output manageable
305+ let fs = Strom.fromArray [(_ + 100)] <> Strom.fromArray [(_ * (-1))]
306+ let xs = Strom.fromArray [1, 2, 3] <> Strom.fromArray [4, 5]
307+ applyResult <- runOm $ (fs <*> xs) # Strom.runCollect
308+ let ap' mf ma = mf >>= \f -> ma >>= \a -> pure (f a)
309+ apResult <- runOm $ ap' (Strom.fromArray [(_ + 100)] <> Strom.fromArray [(_ * (-1))]) (Strom.fromArray [1, 2, 3] <> Strom.fromArray [4, 5]) # Strom.runCollect
310+ applyResult `shouldEqual` apResult
311+
312+ describe "Bind across chunk boundaries" do
313+
314+ it "bind with multi-chunk source preserves all elements" do
315+ -- rangeStrom 1 2001 produces two 1000-element chunks
316+ -- Binding each to a small stream should produce all elements
317+ result <- runOm $
318+ Strom.rangeStrom 1 2001
319+ >>= (\n -> Strom.succeed (n * 2))
320+ # Strom.runCollect
321+ result `shouldEqual` map (_ * 2) (Array.range 1 2000)
322+
323+ it "bind with multi-chunk source and multi-element expansion" do
324+ -- Each element expands to [n, n*10], creating 2x elements
325+ result <- runOm $
326+ Strom.rangeStrom 1 51
327+ >>= (\n -> Strom.fromArray [n, n * 10])
328+ # Strom.runCollect
329+ let expected = Array.range 1 50 >>= (\n -> [n, n * 10])
330+ result `shouldEqual` expected
331+
332+ it "bind with multi-chunk source and variable-size expansion" do
333+ -- Some elements expand to 0, 1, or 2 elements (like filterMap + flatMap)
334+ let expand n
335+ | n `mod` 3 == 0 = Strom.empty
336+ | n `mod` 3 == 1 = Strom.succeed n
337+ | otherwise = Strom.fromArray [n, n * 100]
338+ result <- runOm $
339+ Strom.rangeStrom 1 2001
340+ >>= expand
341+ # Strom.runCollect
342+ let expected = Array.range 1 2000 >>= \n ->
343+ if n `mod` 3 == 0 then []
344+ else if n `mod` 3 == 1 then [n]
345+ else [n, n * 100]
346+ result `shouldEqual` expected
347+
348+ it "bind associativity: (m >>= f) >>= g ≡ m >>= (\\x -> f x >>= g)" do
349+ let f n = Strom.fromArray [n, n + 1]
350+ let g n = Strom.fromArray [n * 10]
351+ let m = Strom.fromArray [1, 2, 3] <> Strom.fromArray [4, 5]
352+ result1 <- runOm $ ((m >>= f) >>= g) # Strom.runCollect
353+ result2 <- runOm $ (m >>= (\x -> f x >>= g)) # Strom.runCollect
354+ result1 `shouldEqual` result2
355+
356+ describe "Cancel propagation" do
357+
358+ it "zip cancels left stream when right is shorter" do
359+ ref <- liftEffect $ Ref.new false
360+ let
361+ leftStrom = Strom.bracket
362+ (pure unit)
363+ (\_ -> liftEffect $ Ref.write true ref)
364+ (\_ -> Strom.rangeStrom 1 10000)
365+ rightStrom = Strom.fromArray [10, 20, 30]
366+ result <- runOm $ Strom.zipStrom leftStrom rightStrom # Strom.runCollect
367+ result `shouldEqual` [Tuple 1 10, Tuple 2 20, Tuple 3 30]
368+ -- Give async cancel a moment to propagate
369+ delay (Milliseconds 50.0)
370+ released <- liftEffect $ Ref.read ref
371+ released `shouldEqual` true
372+
373+ it "zip cancels right stream when left is shorter" do
374+ ref <- liftEffect $ Ref.new false
375+ let
376+ leftStrom = Strom.fromArray [10, 20]
377+ rightStrom = Strom.bracket
378+ (pure unit)
379+ (\_ -> liftEffect $ Ref.write true ref)
380+ (\_ -> Strom.rangeStrom 1 10000)
381+ result <- runOm $ Strom.zipStrom leftStrom rightStrom # Strom.runCollect
382+ result `shouldEqual` [Tuple 10 1, Tuple 20 2]
383+ delay (Milliseconds 50.0)
384+ released <- liftEffect $ Ref.read ref
385+ released `shouldEqual` true
386+
387+ it "takeStrom cancels multi-chunk bracket source" do
388+ ref <- liftEffect $ Ref.new false
389+ let
390+ strom = Strom.bracket
391+ (pure unit)
392+ (\_ -> liftEffect $ Ref.write true ref)
393+ (\_ -> Strom.rangeStrom 1 10000)
394+ result <- runOm $ Strom.takeStrom 5 strom # Strom.runCollect
395+ result `shouldEqual` [1, 2, 3, 4, 5]
396+ released <- liftEffect $ Ref.read ref
397+ released `shouldEqual` true
398+
399+ it "takeWhileStrom cancels multi-chunk bracket source" do
400+ ref <- liftEffect $ Ref.new false
401+ let
402+ strom = Strom.bracket
403+ (pure unit)
404+ (\_ -> liftEffect $ Ref.write true ref)
405+ (\_ -> Strom.rangeStrom 1 10000)
406+ result <- runOm $ Strom.takeWhileStrom (_ <= 3) strom # Strom.runCollect
407+ result `shouldEqual` [1, 2, 3]
408+ released <- liftEffect $ Ref.read ref
409+ released `shouldEqual` true
410+
411+ describe "Stream to file" do
412+
413+ it "streams 1000 random ints to a file" do
414+ let path = "/tmp/strom-test-random.txt"
415+ runOm $ FileStream.streamRandomToFile path 1000
416+ lineCount <- liftEffect $ FileStream.countLines path
417+ lineCount `shouldEqual` 1000
418+ liftEffect $ FileStream._unlinkFile path
419+
420+ it "streams 10000 random ints to a file (multi-chunk)" do
421+ let path = "/tmp/strom-test-random-large.txt"
422+ runOm $ FileStream.streamRandomToFile path 10000
423+ lineCount <- liftEffect $ FileStream.countLines path
424+ lineCount `shouldEqual` 10000
425+ -- Read back and verify all lines parse as ints
426+ content <- liftEffect $ FileStream._readFile path
427+ let lines = Array.filter (_ /= "") $ String.split (String.Pattern "\n") content
428+ Array.length lines `shouldEqual` 10000
429+ liftEffect $ FileStream._unlinkFile path
_excluded/Strom.test.pursnew file+669
@@ -0,0 +1,669 @@
1+module Yoga.Om.Strom.Test where
2+
3+import Prelude
4+
5+import Control.Alt ((<|>))
6+import Data.Array as Array
7+import Data.Maybe (Maybe(..))
8+import Data.Tuple (Tuple(..))
9+import Data.Time.Duration (Milliseconds(..))
10+import Effect.Aff (Aff)
11+import Effect.Ref as Ref
12+import Effect.Class (liftEffect)
13+import Effect.Exception (throwException, error)
14+import Test.Spec (Spec, describe, it)
15+import Test.Spec.Assertions (shouldEqual, fail)
16+import Yoga.Om (Om)
17+import Yoga.Om as Om
18+import Yoga.Om.Strom as Strom
19+
20+-- Helper to run Om in tests
21+runOm :: forall a. Om {} () a -> Aff a
22+runOm om = Om.runOm {} { exception: \err -> liftEffect (throwException err) } om
23+
24+spec :: Spec Unit
25+spec = do
26+ describe "Strom" do
27+
28+ describe "Construction" do
29+
30+ it "empty produces no elements" do
31+ result <- runOm $ Strom.empty # Strom.runCollect
32+ result `shouldEqual` ([] :: Array Int)
33+
34+ it "succeed produces single element" do
35+ result <- runOm $ Strom.succeed 42 # Strom.runCollect
36+ result `shouldEqual` [ 42 ]
37+
38+ it "fromArray produces all elements" do
39+ result <- runOm $ Strom.fromArray [ 1, 2, 3, 4, 5 ] # Strom.runCollect
40+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
41+
42+ it "fromArray handles empty array" do
43+ result <- runOm $ Strom.fromArray [] # Strom.runCollect
44+ result `shouldEqual` ([] :: Array Int)
45+
46+ it "range produces correct sequence" do
47+ result <- runOm $ Strom.rangeStrom 1 6 # Strom.runCollect
48+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
49+
50+ it "range with start >= end produces empty" do
51+ result <- runOm $ Strom.rangeStrom 5 5 # Strom.runCollect
52+ result `shouldEqual` []
53+
54+ it "iterate produces sequence" do
55+ result <- runOm $
56+ Strom.iterateStrom (_ + 1) 0
57+ # Strom.takeStrom 5
58+ # Strom.runCollect
59+ result `shouldEqual` [ 0, 1, 2, 3, 4 ]
60+
61+ it "iterateInfinite works for large streams (15k elements)" do
62+ result <- runOm $
63+ Strom.iterateStromInfinite (_ + 1) 0
64+ # Strom.takeStrom 15000
65+ # Strom.dropStrom 14995
66+ # Strom.runCollect
67+ result `shouldEqual` [ 14995, 14996, 14997, 14998, 14999 ]
68+
69+ it "repeat produces repeated values" do
70+ result <- runOm $
71+ Strom.repeatStrom 42
72+ # Strom.takeStrom 3
73+ # Strom.runCollect
74+ result `shouldEqual` [ 42, 42, 42 ]
75+
76+ it "repeatInfinite works for large streams (15k elements)" do
77+ result <- runOm $
78+ Strom.repeatStromInfinite 99
79+ # Strom.takeStrom 15000
80+ # Strom.dropStrom 14997
81+ # Strom.runCollect
82+ result `shouldEqual` [ 99, 99, 99 ]
83+
84+ it "unfold produces Fibonacci sequence" do
85+ result <- runOm $
86+ Strom.unfoldStrom
87+ (\(Tuple a b) -> if a > 100 then Nothing else Just (Tuple a (Tuple b (a + b))))
88+ (Tuple 0 1)
89+ # Strom.runCollect
90+ result `shouldEqual` [ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 ]
91+
92+ it "fromOm produces value from effect" do
93+ result <- runOm $
94+ Strom.fromOm (pure 42)
95+ # Strom.runCollect
96+ result `shouldEqual` [ 42 ]
97+
98+ describe "Running/Consuming" do
99+
100+ it "runCollect collects all elements" do
101+ result <- runOm $
102+ Strom.rangeStrom 1 6
103+ # Strom.runCollect
104+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
105+
106+ it "runDrain executes but returns unit" do
107+ result <- runOm $
108+ Strom.rangeStrom 1 100
109+ # Strom.runDrain
110+ result `shouldEqual` unit
111+
112+ it "runFold reduces stream" do
113+ result <- runOm $
114+ Strom.rangeStrom 1 6
115+ # Strom.runFold 0 (+)
116+ result `shouldEqual` 15
117+
118+ it "traverse_ executes effects" do
119+ countRef <- liftEffect $ Ref.new 0
120+ _ <- runOm $
121+ Strom.rangeStrom 1 6
122+ # Strom.traverseStrom_ (\_ -> liftEffect $ Ref.modify_ (_ + 1) countRef)
123+ count <- liftEffect $ Ref.read countRef
124+ count `shouldEqual` 5
125+
126+ describe "Transformations" do
127+
128+ it "map transforms elements" do
129+ result <- runOm $
130+ Strom.rangeStrom 1 6
131+ # Strom.mapStrom (_ * 2)
132+ # Strom.runCollect
133+ result `shouldEqual` [ 2, 4, 6, 8, 10 ]
134+
135+ it "mapM with effects" do
136+ result <- runOm $
137+ Strom.fromArray [ 1, 2, 3 ]
138+ # Strom.mapMStrom (\n -> pure (n * 2))
139+ # Strom.runCollect
140+ result `shouldEqual` [ 2, 4, 6 ]
141+
142+ -- TODO: mapParallel not yet implemented
143+ -- it "mapParallel processes concurrently" do
144+ -- result <- runOm $
145+ -- Strom.fromArray [1, 2, 3, 4, 5]
146+ -- # Strom.mapMStrom (\n -> do
147+ -- Om.delay (Milliseconds 10.0)
148+ -- pure (n * 2)
149+ -- )
150+ -- # Strom.runCollect
151+ -- Array.sort result `shouldEqual` [2, 4, 6, 8, 10]
152+
153+ it "bind (>>=) flattens nested streams" do
154+ result <- runOm $
155+ Strom.fromArray [ 1, 2, 3 ]
156+ >>= (\n -> Strom.fromArray [ n, n * 10 ])
157+ # Strom.runCollect
158+ result `shouldEqual` [ 1, 10, 2, 20, 3, 30 ]
159+
160+ it "bind (>>=) preserves order with multi-chunk source" do
161+ -- rangeStrom 1 2001 produces two 1000-element chunks, exercising the Loop branch
162+ result <- runOm $
163+ Strom.rangeStrom 1 2001
164+ >>= (\n -> Strom.succeed (n * 2))
165+ # Strom.takeStrom 2005
166+ # Strom.runCollect
167+ result `shouldEqual` (Array.range 1 2000 # map (_ * 2))
168+
169+ it "scan produces running totals" do
170+ result <- runOm $
171+ Strom.fromArray [ 1, 2, 3, 4, 5 ]
172+ # Strom.scanStrom (+) 0
173+ # Strom.runCollect
174+ result `shouldEqual` [ 1, 3, 6, 10, 15 ]
175+
176+ it "mapAccum with state" do
177+ result <- runOm $
178+ Strom.fromArray [ "a", "b", "c" ]
179+ # Strom.mapAccumStrom (\i x -> Tuple (i + 1) (show i <> x)) 1
180+ # Strom.runCollect
181+ result `shouldEqual` [ "1a", "2b", "3c" ]
182+
183+ it "tap observes without modifying" do
184+ sideEffectRef <- liftEffect $ Ref.new 0
185+ result <- runOm $
186+ Strom.fromArray [ 1, 2, 3 ]
187+ # Strom.tapStrom (\n -> let _ = n in unit) -- Pure observation
188+ # Strom.runCollect
189+ result `shouldEqual` [ 1, 2, 3 ]
190+
191+ it "tapM with effects" do
192+ countRef <- liftEffect $ Ref.new 0
193+ result <- runOm $
194+ Strom.fromArray [ 1, 2, 3 ]
195+ # Strom.tapMStrom (\_ -> liftEffect $ Ref.modify_ (_ + 1) countRef)
196+ # Strom.runCollect
197+ count <- liftEffect $ Ref.read countRef
198+ count `shouldEqual` 3
199+ result `shouldEqual` [ 1, 2, 3 ]
200+
201+ describe "Selection" do
202+
203+ it "take limits elements" do
204+ result <- runOm $
205+ Strom.rangeStrom 1 100
206+ # Strom.takeStrom 5
207+ # Strom.runCollect
208+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
209+
210+ it "take with 0 produces empty" do
211+ result <- runOm $
212+ Strom.rangeStrom 1 100
213+ # Strom.takeStrom 0
214+ # Strom.runCollect
215+ result `shouldEqual` []
216+
217+ it "takeWhile stops at condition" do
218+ result <- runOm $
219+ Strom.rangeStrom 1 10
220+ # Strom.takeWhileStrom (_ < 5)
221+ # Strom.runCollect
222+ result `shouldEqual` [ 1, 2, 3, 4 ]
223+
224+ it "takeUntil includes matching element" do
225+ result <- runOm $
226+ Strom.rangeStrom 1 10
227+ # Strom.takeUntilStrom (_ == 5)
228+ # Strom.runCollect
229+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
230+
231+ it "drop skips elements" do
232+ result <- runOm $
233+ Strom.rangeStrom 1 10
234+ # Strom.dropStrom 5
235+ # Strom.runCollect
236+ result `shouldEqual` [ 6, 7, 8, 9 ]
237+
238+ it "dropWhile skips until condition" do
239+ result <- runOm $
240+ Strom.rangeStrom 1 10
241+ # Strom.dropWhileStrom (_ < 5)
242+ # Strom.runCollect
243+ result `shouldEqual` [ 5, 6, 7, 8, 9 ]
244+
245+ it "filter keeps matching elements" do
246+ result <- runOm $
247+ Strom.rangeStrom 1 10
248+ # Strom.filterStrom (\n -> n `mod` 2 == 0)
249+ # Strom.runCollect
250+ result `shouldEqual` [ 2, 4, 6, 8 ]
251+
252+ it "filterM with effects" do
253+ result <- runOm $
254+ Strom.rangeStrom 1 10
255+ # Strom.filterMStrom (\n -> pure (n `mod` 2 == 0))
256+ # Strom.runCollect
257+ result `shouldEqual` [ 2, 4, 6, 8 ]
258+
259+ it "collect filters and maps" do
260+ result <- runOm $
261+ Strom.fromArray [ "1", "x", "2", "y", "3" ]
262+ # Strom.collectStrom parseNumber
263+ # Strom.runCollect
264+ result `shouldEqual` [ 1, 2, 3 ]
265+
266+ it "changes removes consecutive duplicates" do
267+ result <- runOm $
268+ Strom.fromArray [ 1, 1, 2, 2, 2, 3, 3, 1, 1 ]
269+ # Strom.changesStrom
270+ # Strom.runCollect
271+ result `shouldEqual` [ 1, 2, 3, 1 ]
272+
273+ describe "Combining" do
274+
275+ it "append concatenates streams" do
276+ result <- runOm $
277+ Strom.fromArray [ 1, 2, 3 ]
278+ # Strom.appendStrom (Strom.fromArray [ 4, 5, 6 ])
279+ # Strom.runCollect
280+ result `shouldEqual` [ 1, 2, 3, 4, 5, 6 ]
281+
282+ it "concat multiple streams" do
283+ result <- runOm $
284+ Strom.concatStrom
285+ [ Strom.fromArray [ 1, 2 ]
286+ , Strom.fromArray [ 3, 4 ]
287+ , Strom.fromArray [ 5, 6 ]
288+ ]
289+ # Strom.runCollect
290+ result `shouldEqual` [ 1, 2, 3, 4, 5, 6 ]
291+
292+ it "zip pairs elements" do
293+ result <- runOm $
294+ Strom.zipStrom
295+ (Strom.rangeStrom 1 4)
296+ (Strom.fromArray [ "a", "b", "c" ])
297+ # Strom.runCollect
298+ result `shouldEqual` [ Tuple 1 "a", Tuple 2 "b", Tuple 3 "c" ]
299+
300+ it "zip stops at shortest stream" do
301+ result <- runOm $
302+ Strom.zipStrom
303+ (Strom.rangeStrom 1 10)
304+ (Strom.fromArray [ "a", "b" ])
305+ # Strom.runCollect
306+ result `shouldEqual` [ Tuple 1 "a", Tuple 2 "b" ]
307+
308+ it "zipWith with custom function" do
309+ result <- runOm $
310+ Strom.zipWithStrom (+)
311+ (Strom.rangeStrom 1 4)
312+ (Strom.rangeStrom 10 13)
313+ # Strom.runCollect
314+ result `shouldEqual` [ 11, 13, 15 ]
315+
316+ it "zip handles mismatched chunk sizes (multi-chunk vs single-chunk)" do
317+ -- Regression: previously dropped elements when one stream had more chunks
318+ result <- runOm $
319+ Strom.zipStrom
320+ (Strom.rangeStrom 1 2001) -- two 1000-element chunks
321+ (Strom.fromArray [ 10, 20, 30 ]) -- single chunk
322+ # Strom.runCollect
323+ result `shouldEqual` [ Tuple 1 10, Tuple 2 20, Tuple 3 30 ]
324+
325+ it "interleave alternates chunks from two streams" do
326+ result <- runOm $
327+ Strom.interleave
328+ (Strom.fromArray [ 1, 3, 5 ])
329+ (Strom.fromArray [ 2, 4, 6 ])
330+ # Strom.runCollect
331+ -- fromArray produces single chunks, so interleave emits first chunk then second
332+ result `shouldEqual` [ 1, 3, 5, 2, 4, 6 ]
333+
334+ it "interleave terminates with finite streams" do
335+ -- Regression: previously infinite looped because Done(Just) streams were re-pulled
336+ result <- runOm $
337+ Strom.interleave
338+ (Strom.succeed 1)
339+ (Strom.succeed 2)
340+ # Strom.runCollect
341+ result `shouldEqual` [ 1, 2 ]
342+
343+ it "race takes first to emit" do
344+ result <- runOm $
345+ -- TODO: race not yet implemented
346+ Strom.fromOm (pure 2)
347+ # Strom.runCollect
348+ -- Result should be from the fastest stream
349+ result `shouldEqual` [ 2 ]
350+
351+ describe "Non-Deterministic Merge" do
352+
353+ it "mergeND merges two streams concurrently" do
354+ result <- runOm $
355+ Strom.mergeND
356+ (Strom.fromArray [ 1, 2, 3 ])
357+ (Strom.fromArray [ 10, 20, 30 ])
358+ # Strom.runCollect
359+ -- All elements should be present (order may vary)
360+ Array.sort result `shouldEqual` [ 1, 2, 3, 10, 20, 30 ]
361+
362+ it "mergeND handles fast and slow streams" do
363+ result <- runOm $
364+ Strom.mergeND
365+ ( Strom.fromArray [ 1, 2, 3 ]
366+ # Strom.mapMStrom (\n -> Om.delay (Milliseconds 50.0) *> pure n)
367+ )
368+ ( Strom.fromArray [ 10, 20, 30 ]
369+ # Strom.mapMStrom (\n -> Om.delay (Milliseconds 5.0) *> pure n)
370+ )
371+ # Strom.runCollect
372+ -- All elements should be present
373+ Array.sort result `shouldEqual` [ 1, 2, 3, 10, 20, 30 ]
374+ -- Fast stream elements (10, 20, 30) should appear earlier
375+ -- (This is probabilistic but highly likely with 10x speed difference)
376+ let fastElements = Array.filter (\n -> n >= 10) result
377+ Array.length fastElements `shouldEqual` 3
378+
379+ it "mergeND handles empty streams" do
380+ result <- runOm $
381+ Strom.mergeND
382+ (Strom.empty :: Strom.Strom {} () Int)
383+ (Strom.fromArray [ 1, 2, 3 ])
384+ # Strom.runCollect
385+ result `shouldEqual` [ 1, 2, 3 ]
386+
387+ it "mergeND handles both streams empty" do
388+ result <- runOm $
389+ Strom.mergeND
390+ (Strom.empty :: Strom.Strom {} () Int)
391+ (Strom.empty :: Strom.Strom {} () Int)
392+ # Strom.runCollect
393+ result `shouldEqual` []
394+
395+ it "mergeND handles one stream finishing first" do
396+ result <- runOm $
397+ Strom.mergeND
398+ (Strom.fromArray [ 1, 2 ])
399+ (Strom.fromArray [ 10, 20, 30, 40 ])
400+ # Strom.runCollect
401+ Array.sort result `shouldEqual` [ 1, 2, 10, 20, 30, 40 ]
402+
403+ it "mergeND with takeStrom limits output" do
404+ result <- runOm $
405+ Strom.mergeND
406+ (Strom.repeatStromInfinite 1)
407+ (Strom.repeatStromInfinite 2)
408+ # Strom.takeStrom 10
409+ # Strom.runCollect
410+ -- Should limit to 10 elements
411+ Array.length result `shouldEqual` 10
412+ -- Should contain only 1s or 2s (or a mix)
413+ -- Note: When both streams are equally fast, Om.race may consistently pick the same one
414+ let allValid = Array.all (\n -> n == 1 || n == 2) result
415+ allValid `shouldEqual` true
416+
417+ it "mergeAllND merges multiple streams" do
418+ result <- runOm $
419+ Strom.mergeAllND
420+ [ Strom.fromArray [ 1, 2 ]
421+ , Strom.fromArray [ 10, 20 ]
422+ , Strom.fromArray [ 100, 200 ]
423+ ]
424+ # Strom.runCollect
425+ Array.sort result `shouldEqual` [ 1, 2, 10, 20, 100, 200 ]
426+
427+ it "mergeAllND handles empty array" do
428+ result <- runOm $
429+ Strom.mergeAllND ([] :: Array (Strom.Strom {} () Int))
430+ # Strom.runCollect
431+ result `shouldEqual` []
432+
433+ describe "Grouping" do
434+
435+ it "grouped creates fixed-size chunks" do
436+ result <- runOm $
437+ Strom.rangeStrom 1 11
438+ # Strom.groupedStrom 3
439+ # Strom.runCollect
440+ result `shouldEqual` [ [ 1, 2, 3 ], [ 4, 5, 6 ], [ 7, 8, 9 ], [ 10 ] ]
441+
442+ it "grouped with partial last chunk" do
443+ result <- runOm $
444+ Strom.rangeStrom 1 8
445+ # Strom.groupedStrom 3
446+ # Strom.runCollect
447+ result `shouldEqual` [ [ 1, 2, 3 ], [ 4, 5, 6 ], [ 7 ] ]
448+
449+ it "grouped preserves all elements with multi-chunk source" do
450+ -- rangeStrom 1 2001 produces two 1000-element chunks.
451+ -- groupedStrom 500 means chunk boundaries align with group size
452+ -- (1000 is a multiple of 500), which previously dropped complete last groups.
453+ result <- runOm $
454+ Strom.rangeStrom 1 2001
455+ # Strom.groupedStrom 500
456+ # Strom.mapStrom Array.length
457+ # Strom.runCollect
458+ result `shouldEqual` [ 500, 500, 500, 500 ]
459+
460+ it "chunked is alias for grouped" do
461+ result <- runOm $
462+ Strom.rangeStrom 1 6
463+ # Strom.chunkedStrom 2
464+ # Strom.runCollect
465+ result `shouldEqual` [ [ 1, 2 ], [ 3, 4 ], [ 5 ] ]
466+
467+ it "partition splits by predicate" do
468+ -- TODO: partition not yet implemented
469+ let stream = Strom.rangeStrom 1 11
470+ evensResult <- runOm $ Strom.runCollect $ stream # Strom.filterStrom (\n -> n `mod` 2 == 0)
471+ oddsResult <- runOm $ Strom.runCollect $ stream # Strom.filterStrom (\n -> n `mod` 2 /= 0)
472+ evensResult `shouldEqual` [ 2, 4, 6, 8, 10 ]
473+ oddsResult `shouldEqual` [ 1, 3, 5, 7, 9 ]
474+
475+ describe "Timing Operations" do
476+
477+ it "debounce adds delay between elements" do
478+ result <- runOm $
479+ Strom.fromArray [ 1, 2, 3 ]
480+ # Strom.debounce (Milliseconds 10.0)
481+ # Strom.runCollect
482+ -- Verify all elements pass through
483+ result `shouldEqual` [ 1, 2, 3 ]
484+
485+ it "throttle limits emission rate" do
486+ result <- runOm $
487+ Strom.fromArray [ 1, 2, 3, 4, 5 ]
488+ # Strom.throttle (Milliseconds 5.0)
489+ # Strom.runCollect
490+ -- First element emits immediately, rest are throttled
491+ -- Due to fast sequential processing, likely only first element passes
492+ Array.length result `shouldEqual` 1
493+ Array.head result `shouldEqual` Just 1
494+
495+ describe "Error Handling" do
496+
497+ it "catchAll recovers from errors" do
498+ result <- runOm $
499+ Strom.fromArray [ 1, 2, 3 ]
500+ # Strom.mapMStrom
501+ ( \n ->
502+ if n == 2 then pure 999 -- TODO: catchAll not implemented
503+ else pure (n * 2)
504+ )
505+ # Strom.runCollect
506+ -- Should have gotten an error and recovered
507+ result `shouldEqual` [ 2, 999, 6 ]
508+
509+ it "orElse provides alternative" do
510+ let
511+ failingStream = Strom.empty
512+ successStream = Strom.fromArray [ 1, 2, 3 ]
513+ result <- runOm $
514+ (failingStream <|> successStream)
515+ # Strom.runCollect
516+ result `shouldEqual` [ 1, 2, 3 ]
517+
518+ describe "Complex Scenarios" do
519+
520+ it "pipeline: filter, map, group" do
521+ result <- runOm $
522+ Strom.rangeStrom 1 21
523+ # Strom.filterStrom (\n -> n `mod` 2 == 0)
524+ # Strom.mapStrom (_ * 2)
525+ # Strom.groupedStrom 3
526+ # Strom.runCollect
527+ result `shouldEqual` [ [ 4, 8, 12 ], [ 16, 20, 24 ], [ 28, 32, 36 ], [ 40 ] ]
528+
529+ it "nested bind (>>=) with filtering" do
530+ result <- runOm $
531+ Strom.fromArray [ 1, 2, 3 ]
532+ >>=
533+ ( \n ->
534+ Strom.rangeStrom (n * 10) (n * 10 + 3)
535+ # Strom.filterStrom (\x -> x `mod` 2 == 0)
536+ )
537+ # Strom.runCollect
538+ result `shouldEqual` [ 10, 12, 20, 22, 30, 32 ]
539+
540+ it "scan with filtering and mapping" do
541+ result <- runOm $
542+ Strom.fromArray [ 1, -2, 3, -4, 5 ]
543+ # Strom.filterStrom (_ > 0)
544+ # Strom.scanStrom (+) 0
545+ # Strom.mapStrom (_ * 10)
546+ # Strom.runCollect
547+ result `shouldEqual` [ 10, 40, 90 ]
548+
549+ it "zip with scan" do
550+ result <- runOm $
551+ Strom.zipStrom
552+ (Strom.fromArray [ "a", "b", "c", "d" ])
553+ (Strom.iterateStrom (_ + 1) 1 # Strom.scanStrom (+) 0 # Strom.takeStrom 4)
554+ # Strom.runCollect
555+ result `shouldEqual` [ Tuple "a" 1, Tuple "b" 3, Tuple "c" 6, Tuple "d" 10 ]
556+
557+ it "large stream with take" do
558+ result <- runOm $
559+ Strom.iterateStrom (_ + 1) 0
560+ # Strom.filterStrom (\n -> n `mod` 3 == 0)
561+ # Strom.takeStrom 100
562+ # Strom.groupedStrom 10
563+ # Strom.runCollect
564+ Array.length result `shouldEqual` 10
565+ (Array.head result >>= Array.head) `shouldEqual` Just 0
566+
567+ describe "Edge Cases" do
568+
569+ it "empty stream through pipeline" do
570+ result <- runOm $
571+ Strom.empty
572+ # Strom.mapStrom (_ * 2)
573+ # Strom.filterStrom (_ > 0)
574+ # Strom.groupedStrom 10
575+ # Strom.runCollect
576+ result `shouldEqual` ([] :: Array (Array Int))
577+
578+ it "single element through complex pipeline" do
579+ result <- runOm $
580+ Strom.succeed 42
581+ # Strom.mapStrom (_ * 2)
582+ # Strom.filterStrom (_ > 50)
583+ # Strom.takeStrom 10
584+ # Strom.runCollect
585+ result `shouldEqual` [ 84 ]
586+
587+ it "take more than available" do
588+ result <- runOm $
589+ Strom.fromArray [ 1, 2, 3 ]
590+ # Strom.takeStrom 100
591+ # Strom.runCollect
592+ result `shouldEqual` [ 1, 2, 3 ]
593+
594+ it "drop more than available" do
595+ result <- runOm $
596+ Strom.fromArray [ 1, 2, 3 ]
597+ # Strom.dropStrom 100
598+ # Strom.runCollect
599+ result `shouldEqual` []
600+
601+ it "grouped with size larger than stream" do
602+ result <- runOm $
603+ Strom.fromArray [ 1, 2, 3 ]
604+ # Strom.groupedStrom 100
605+ # Strom.runCollect
606+ result `shouldEqual` [ [ 1, 2, 3 ] ]
607+
608+ describe "Cancellation" do
609+
610+ it "cancelStrom on simple stream is no-op" do
611+ liftEffect $ Strom.cancelStrom (Strom.fromArray [ 1, 2, 3 ] :: Strom.Strom {} () Int)
612+
613+ it "takeStrom + bracket releases resource" do
614+ ref <- liftEffect $ Ref.new false
615+ let
616+ strom = Strom.bracket
617+ (pure unit)
618+ (\_ -> liftEffect $ Ref.write true ref)
619+ (\_ -> Strom.rangeStrom 1 1000)
620+ result <- runOm $ Strom.runCollect (Strom.takeStrom 5 strom)
621+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
622+ released <- liftEffect $ Ref.read ref
623+ released `shouldEqual` true
624+
625+ it "takeWhileStrom + bracket releases resource" do
626+ ref <- liftEffect $ Ref.new false
627+ let
628+ strom = Strom.bracket
629+ (pure unit)
630+ (\_ -> liftEffect $ Ref.write true ref)
631+ (\_ -> Strom.rangeStrom 1 1000)
632+ result <- runOm $ Strom.runCollect (Strom.takeWhileStrom (_ < 4) strom)
633+ result `shouldEqual` [ 1, 2, 3 ]
634+ released <- liftEffect $ Ref.read ref
635+ released `shouldEqual` true
636+
637+ it "bracket releases resource on natural multi-chunk completion" do
638+ ref <- liftEffect $ Ref.new false
639+ let
640+ -- rangeStrom 1 2001 produces two chunks (>1000 elements), so bracket sees Loop first
641+ strom = Strom.bracket
642+ (pure unit)
643+ (\_ -> liftEffect $ Ref.write true ref)
644+ (\_ -> Strom.rangeStrom 1 2001)
645+ result <- runOm $ Strom.runCollect strom
646+ Array.length result `shouldEqual` 2000
647+ released <- liftEffect $ Ref.read ref
648+ released `shouldEqual` true
649+
650+ it "takeUntilStrom + bracket releases resource" do
651+ ref <- liftEffect $ Ref.new false
652+ let
653+ strom = Strom.bracket
654+ (pure unit)
655+ (\_ -> liftEffect $ Ref.write true ref)
656+ (\_ -> Strom.rangeStrom 1 1000)
657+ result <- runOm $ Strom.runCollect (Strom.takeUntilStrom (_ == 5) strom)
658+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
659+ released <- liftEffect $ Ref.read ref
660+ released `shouldEqual` true
661+
662+-- Helper functions
663+parseNumber :: String -> Maybe Int
664+parseNumber "1" = Just 1
665+parseNumber "2" = Just 2
666+parseNumber "3" = Just 3
667+parseNumber "4" = Just 4
668+parseNumber "5" = Just 5
669+parseNumber _ = Nothing
src/Test/Main.purs → _excluded/Test/Main.pursrenamed+4
@@ -6,8 +6,12 @@ import Effect (Effect)
66import Effect.Aff (launchAff_)
77import Test.Spec.Reporter (consoleReporter)
88import Test.Spec.Runner (runSpec)
9+import Test.WebStream as WebStreamSpec
910import Yoga.Om.Strom.Test as StromSpec
11+import Yoga.Om.Strom.PropTest as StromPropSpec
1012
1113main :: Effect Unit
1214main = launchAff_ $ runSpec [consoleReporter] do
1315 StromSpec.spec
16+ WebStreamSpec.spec
17+ StromPropSpec.spec
spago.lock+357−10
@@ -6,7 +6,7 @@
66 "core": {
77 "dependencies": [
88 {
9− "aff": ">=7.1.0 <8.0.0"
9+ "aff": ">=8.0.0 <9.0.0"
1010 },
1111 {
1212 "arrays": ">=7.3.0 <8.0.0"
@@ -57,7 +57,7 @@
5757 "refs": ">=6.0.0 <7.0.0"
5858 },
5959 {
60− "spec": ">=7.6.1 <8.0.0"
60+ "spec": ">=8.1.2 <9.0.0"
6161 },
6262 {
6363 "st": ">=6.2.0 <7.0.0"
@@ -75,14 +75,17 @@
7575 "variant": ">=8.0.0 <9.0.0"
7676 },
7777 {
78− "yoga-om": ">=1.2.2 <2.0.0"
78+ "yoga-om": ">=1.5.2 <2.0.0"
7979 }
8080 ]
8181 },
8282 "test": {
8383 "dependencies": [
8484 {
85− "spec": ">=7.0.0 <8.0.0"
85+ "spec": ">=8.0.0 <9.0.0"
86+ },
87+ {
88+ "spec-node": ">=0.0.3 <0.1.0"
8689 }
8790 ]
8891 }
@@ -93,8 +96,8 @@
9396 "packages": {
9497 "aff": {
9598 "type": "registry",
96− "version": "7.1.0",
97− "integrity": "sha256-/nOpDQVnKIoP1pQ1ZZBYpadK0TuMlG4WwufGzJpsfD0=",
99+ "version": "8.0.0",
100+ "integrity": "sha256-9BRIrSdRoYybfiUmBqgKJ66cPqxYHb277bVWQMn/9sg=",
98101 "dependencies": [
99102 "control",
100103 "datetime",
@@ -112,6 +115,26 @@
112115 "unsafe-coerce"
113116 ]
114117 },
118+ "aff-retry": {
119+ "type": "registry",
120+ "version": "2.0.0",
121+ "integrity": "sha256-rKZNcP4IKSyBFQBVzrCvE0ULoJt0N/uDLLJ0dPotr4k=",
122+ "dependencies": [
123+ "aff",
124+ "arrays",
125+ "datetime",
126+ "effect",
127+ "either",
128+ "exceptions",
129+ "integers",
130+ "maybe",
131+ "newtype",
132+ "numbers",
133+ "prelude",
134+ "random",
135+ "transformers"
136+ ]
137+ },
115138 "ansi": {
116139 "type": "registry",
117140 "version": "7.0.0",
@@ -122,6 +145,53 @@
122145 "prelude"
123146 ]
124147 },
148+ "argonaut-codecs": {
149+ "type": "registry",
150+ "version": "9.1.0",
151+ "integrity": "sha256-K910SBrmYallESsm8pxJYThs7lig6hxX3M4PW33Cgew=",
152+ "dependencies": [
153+ "argonaut-core",
154+ "arrays",
155+ "bifunctors",
156+ "either",
157+ "foldable-traversable",
158+ "foreign-object",
159+ "identity",
160+ "integers",
161+ "lists",
162+ "maybe",
163+ "nonempty",
164+ "ordered-collections",
165+ "prelude",
166+ "record",
167+ "strings",
168+ "tuples"
169+ ]
170+ },
171+ "argonaut-core": {
172+ "type": "registry",
173+ "version": "7.0.0",
174+ "integrity": "sha256-gYZihtBIKthVuXXoiPphMookSf8M5aHyFYkcIWqlxTM=",
175+ "dependencies": [
176+ "arrays",
177+ "control",
178+ "either",
179+ "foreign-object",
180+ "functions",
181+ "gen",
182+ "maybe",
183+ "nonempty",
184+ "prelude",
185+ "strings",
186+ "tailrec"
187+ ]
188+ },
189+ "arraybuffer-types": {
190+ "type": "registry",
191+ "version": "3.0.2",
192+ "integrity": "sha256-p05cJnSkyeoB7VHzMyc2Eb1RwUaB7Nl0sQSqEGNEUD8=",
193+ "dependencies": []
194+ },
125195 "arrays": {
126196 "type": "registry",
127197 "version": "7.3.0",
@@ -313,6 +383,16 @@
313383 "unsafe-coerce"
314384 ]
315385 },
386+ "exitcodes": {
387+ "type": "registry",
388+ "version": "4.0.0",
389+ "integrity": "sha256-xBl4aFSEQ05Owp1ZvO6OxxvLK3LVzU35RSbj79SsePQ=",
390+ "dependencies": [
391+ "enums",
392+ "maybe",
393+ "prelude"
394+ ]
395+ },
316396 "foldable-traversable": {
317397 "type": "registry",
318398 "version": "6.0.0",
@@ -331,6 +411,42 @@
331411 "tuples"
332412 ]
333413 },
414+ "foreign": {
415+ "type": "registry",
416+ "version": "7.0.0",
417+ "integrity": "sha256-jiDRMVQfovGsbbA/FzbzYf6iWk9i2b5gNrIjz+gFfsI=",
418+ "dependencies": [
419+ "either",
420+ "functions",
421+ "integers",
422+ "lists",
423+ "maybe",
424+ "prelude",
425+ "strings",
426+ "transformers",
427+ "unsafe-coerce"
428+ ]
429+ },
430+ "foreign-object": {
431+ "type": "registry",
432+ "version": "4.1.0",
433+ "integrity": "sha256-x/Q7r80z/vmHRKvPwhYmHP/DnJciPtsyGfRTMzayKIU=",
434+ "dependencies": [
435+ "arrays",
436+ "foldable-traversable",
437+ "functions",
438+ "gen",
439+ "lists",
440+ "maybe",
441+ "prelude",
442+ "st",
443+ "tailrec",
444+ "tuples",
445+ "typelevel-prelude",
446+ "unfoldable",
447+ "unsafe-coerce"
448+ ]
449+ },
334450 "fork": {
335451 "type": "registry",
336452 "version": "6.0.0",
@@ -451,6 +567,21 @@
451567 "prelude"
452568 ]
453569 },
570+ "js-date": {
571+ "type": "registry",
572+ "version": "8.0.0",
573+ "integrity": "sha256-DNQrp4xYc8o80jtxo0aOkUeXGi6h0Xidkk2ZQ8Y4l5Y=",
574+ "dependencies": [
575+ "datetime",
576+ "effect",
577+ "enums",
578+ "foreign",
579+ "functions",
580+ "integers",
581+ "maybe",
582+ "prelude"
583+ ]
584+ },
454585 "lazy": {
455586 "type": "registry",
456587 "version": "6.0.0",
@@ -518,6 +649,107 @@
518649 "safe-coerce"
519650 ]
520651 },
652+ "node-buffer": {
653+ "type": "registry",
654+ "version": "9.0.0",
655+ "integrity": "sha256-RdQjilmEHqsT4fwpCr8Mavw8KTafYrS3ZM6zeAAVAl8=",
656+ "dependencies": [
657+ "arraybuffer-types",
658+ "effect",
659+ "functions",
660+ "maybe",
661+ "nullable",
662+ "partial",
663+ "prelude",
664+ "st",
665+ "unsafe-coerce"
666+ ]
667+ },
668+ "node-event-emitter": {
669+ "type": "registry",
670+ "version": "3.0.0",
671+ "integrity": "sha256-wodx71NxJBPXOaawFn01V1ByYB2vT+I3RuV2giOVKMg=",
672+ "dependencies": [
673+ "effect",
674+ "either",
675+ "functions",
676+ "maybe",
677+ "nullable",
678+ "prelude",
679+ "unsafe-coerce"
680+ ]
681+ },
682+ "node-fs": {
683+ "type": "registry",
684+ "version": "9.2.0",
685+ "integrity": "sha256-fxioTSm9y47WuYs9F/7nxCh/RVDCl3Kr3Hg779J4SJA=",
686+ "dependencies": [
687+ "aff",
688+ "datetime",
689+ "effect",
690+ "either",
691+ "enums",
692+ "exceptions",
693+ "functions",
694+ "integers",
695+ "js-date",
696+ "maybe",
697+ "node-buffer",
698+ "node-path",
699+ "node-streams",
700+ "nullable",
701+ "partial",
702+ "prelude",
703+ "strings"
704+ ]
705+ },
706+ "node-path": {
707+ "type": "registry",
708+ "version": "5.0.1",
709+ "integrity": "sha256-j7n/SmpVz0FOMJl4XZop3ofJ+MeIPkMNqUEUKHEL6Us=",
710+ "dependencies": [
711+ "effect"
712+ ]
713+ },
714+ "node-process": {
715+ "type": "registry",
716+ "version": "11.2.0",
717+ "integrity": "sha256-onzKeNmaeYfN3HSDPqSFTrrP2Yl9tnVsfKKhnf2plxM=",
718+ "dependencies": [
719+ "effect",
720+ "exceptions",
721+ "foreign",
722+ "foreign-object",
723+ "maybe",
724+ "node-event-emitter",
725+ "node-streams",
726+ "nullable",
727+ "posix-types",
728+ "prelude",
729+ "strings"
730+ ]
731+ },
732+ "node-streams": {
733+ "type": "registry",
734+ "version": "9.0.1",
735+ "integrity": "sha256-yuVGm/R/tBr4Nphi+a1a984Z0fkmN9Q5wWtSmbmJTpA=",
736+ "dependencies": [
737+ "aff",
738+ "arrays",
739+ "effect",
740+ "either",
741+ "exceptions",
742+ "maybe",
743+ "node-buffer",
744+ "node-event-emitter",
745+ "nullable",
746+ "prelude",
747+ "refs",
748+ "st",
749+ "tailrec",
750+ "unsafe-coerce"
751+ ]
752+ },
521753 "nonempty": {
522754 "type": "registry",
523755 "version": "7.0.0",
@@ -541,6 +773,16 @@
541773 "prelude"
542774 ]
543775 },
776+ "nullable": {
777+ "type": "registry",
778+ "version": "6.0.0",
779+ "integrity": "sha256-CDAZk+L9Sc0GCFTkE8n+qdp4Ys8avvnkU+m2hVALt7M=",
780+ "dependencies": [
781+ "functions",
782+ "maybe",
783+ "prelude"
784+ ]
785+ },
544786 "numbers": {
545787 "type": "registry",
546788 "version": "9.0.1",
@@ -551,6 +793,56 @@
551793 "prelude"
552794 ]
553795 },
796+ "open-memoize": {
797+ "type": "registry",
798+ "version": "6.2.0",
799+ "integrity": "sha256-yJLuVYX8FocHIJ+6q7efH/Pj4S+e1Y57DZ5Dn7qf4ww=",
800+ "dependencies": [
801+ "either",
802+ "integers",
803+ "lazy",
804+ "lists",
805+ "maybe",
806+ "partial",
807+ "prelude",
808+ "strings",
809+ "tuples"
810+ ]
811+ },
812+ "optparse": {
813+ "type": "registry",
814+ "version": "5.0.1",
815+ "integrity": "sha256-2btDjHPRR0uujUQJuZAN9AzvVoXe7yG6IwnY5evERhQ=",
816+ "dependencies": [
817+ "arrays",
818+ "bifunctors",
819+ "control",
820+ "effect",
821+ "either",
822+ "enums",
823+ "exists",
824+ "exitcodes",
825+ "foldable-traversable",
826+ "free",
827+ "integers",
828+ "lazy",
829+ "lists",
830+ "maybe",
831+ "newtype",
832+ "node-buffer",
833+ "node-process",
834+ "node-streams",
835+ "nonempty",
836+ "numbers",
837+ "open-memoize",
838+ "partial",
839+ "prelude",
840+ "strings",
841+ "tailrec",
842+ "transformers",
843+ "tuples"
844+ ]
845+ },
554846 "ordered-collections": {
555847 "type": "registry",
556848 "version": "3.2.0",
@@ -626,6 +918,16 @@
626918 "tuples"
627919 ]
628920 },
921+ "posix-types": {
922+ "type": "registry",
923+ "version": "6.0.0",
924+ "integrity": "sha256-Je5jG439F/SFO/IvVevZnWVxDIonrehlKuJkefP2lHE=",
925+ "dependencies": [
926+ "maybe",
927+ "newtype",
928+ "prelude"
929+ ]
930+ },
629931 "prelude": {
630932 "type": "registry",
631933 "version": "6.0.2",
@@ -647,6 +949,16 @@
647949 "tuples"
648950 ]
649951 },
952+ "random": {
953+ "type": "registry",
954+ "version": "6.0.0",
955+ "integrity": "sha256-7sZRo6P9UIiXDZ9s2QL9T8h3wlIwLdQrR4+oZKOijQw=",
956+ "dependencies": [
957+ "effect",
958+ "integers",
959+ "prelude"
960+ ]
961+ },
650962 "record": {
651963 "type": "registry",
652964 "version": "4.0.0",
@@ -689,8 +1001,8 @@
6891001 },
6901002 "spec": {
6911003 "type": "registry",
692− "version": "7.6.1",
693− "integrity": "sha256-5GysDOS1gVbbGX75YXUdhww3h2S246Ck0sS6yt+ig+0=",
1004+ "version": "8.1.2",
1005+ "integrity": "sha256-klPPJ6lBQI1CGOAdLJ+ZoGRt8XZippkTKjn53BrSkL0=",
6941006 "dependencies": [
6951007 "aff",
6961008 "ansi",
@@ -721,6 +1033,38 @@
7211033 "tuples"
7221034 ]
7231035 },
1036+ "spec-node": {
1037+ "type": "registry",
1038+ "version": "0.0.3",
1039+ "integrity": "sha256-8/9+g0obbamV0YtqzmttbWs8HaWF7CEeW+eDtqzuAAo=",
1040+ "dependencies": [
1041+ "aff",
1042+ "argonaut-codecs",
1043+ "argonaut-core",
1044+ "arrays",
1045+ "control",
1046+ "datetime",
1047+ "effect",
1048+ "either",
1049+ "foldable-traversable",
1050+ "identity",
1051+ "integers",
1052+ "maybe",
1053+ "newtype",
1054+ "node-buffer",
1055+ "node-fs",
1056+ "node-process",
1057+ "now",
1058+ "numbers",
1059+ "optparse",
1060+ "ordered-collections",
1061+ "partial",
1062+ "prelude",
1063+ "spec",
1064+ "strings",
1065+ "tuples"
1066+ ]
1067+ },
7241068 "st": {
7251069 "type": "registry",
7261070 "version": "6.2.0",
@@ -871,10 +1215,11 @@
8711215 },
8721216 "yoga-om": {
8731217 "type": "registry",
874− "version": "1.2.2",
875− "integrity": "sha256-uDV3/o8mUOTIgSEQFqIATPyladzL8BcjURvfS901hgI=",
1218+ "version": "1.5.2",
1219+ "integrity": "sha256-2ys14BF6NPgzFDFUIFk0EDxmqkA8+yghIA9TXHlOmE0=",
8761220 "dependencies": [
8771221 "aff",
1222+ "aff-retry",
8781223 "avar",
8791224 "console",
8801225 "control",
@@ -883,12 +1228,14 @@
8831228 "either",
8841229 "exceptions",
8851230 "functions",
1231+ "heterogeneous",
8861232 "maybe",
8871233 "newtype",
8881234 "parallel",
8891235 "prelude",
8901236 "record",
8911237 "record-studio",
1238+ "refs",
8921239 "tailrec",
8931240 "transformers",
8941241 "tuples",
spago.yaml+6−6
@@ -1,9 +1,8 @@
11workspace: {}
2−
32package:
43 name: yoga-om-strom
54 publish:
6− version: 0.1.2
5+ version: 0.4.3
76 license: MIT-0
87 exclude:
98 - "src/**/*.test.purs"
@@ -12,7 +11,7 @@ package:
1211 githubOwner: rowtype-yoga
1312 githubRepo: purescript-yoga-om-strom
1413 dependencies:
15− - aff: ">=7.1.0 <8.0.0"
14+ - aff: ">=8.0.0 <9.0.0"
1615 - arrays: ">=7.3.0 <8.0.0"
1716 - avar: ">=5.0.1 <6.0.0"
1817 - catenable-lists: ">=7.0.0 <8.0.0"
@@ -29,14 +28,15 @@ package:
2928 - now: ">=6.0.0 <7.0.0"
3029 - prelude: ">=6.0.2 <7.0.0"
3130 - refs: ">=6.0.0 <7.0.0"
32− - spec: ">=7.6.1 <8.0.0"
31+ - spec: ">=8.1.2 <9.0.0"
3332 - st: ">=6.2.0 <7.0.0"
3433 - tailrec: ">=6.1.0 <7.0.0"
3534 - transformers: ">=6.1.0 <7.0.0"
3635 - tuples: ">=7.0.0 <8.0.0"
3736 - variant: ">=8.0.0 <9.0.0"
38− - yoga-om: ">=1.2.2 <2.0.0"
37+ - yoga-om: ">=1.5.2 <2.0.0"
3938 test:
4039 main: Test.Main.Strom
4140 dependencies:
42− - spec: ">=7.0.0 <8.0.0"
41+ - spec: ">=8.0.0 <9.0.0"
42+ - spec-node: ">=0.0.3 <0.1.0"
src/Benchmark.purs+1−2
@@ -10,10 +10,9 @@ import Data.Tuple (Tuple(..))
1010import Effect (Effect)
1111import Effect.Aff (Aff, launchAff_, throwError)
1212import Effect.Class (liftEffect)
13−import Effect.Exception (Error)
1413import Effect.Class.Console as Console
1514import Effect.Now (now)
16−import Data.DateTime.Instant (unInstant, diff)
15+import Data.DateTime.Instant (diff)
1716import Yoga.Om as Om
1817import Yoga.Om.Strom as Strom
1918
src/Yoga/Om/Strom.purs+264−302
@@ -65,6 +65,7 @@ module Yoga.Om.Strom
6565 , partitionMap
6666 -- Timing
6767 , debounce
68+ , debounceEach
6869 , throttle
6970 , delayStrom
7071 -- Combining
@@ -108,6 +109,9 @@ module Yoga.Om.Strom
108109 , bracket
109110 , bracketExit
110111 , acquireRelease
112+ -- Internal (needed by WebStream)
113+ , mkStrom
114+ , runStrom
111115 ) where
112116
113117import Prelude
@@ -129,18 +133,16 @@ import Data.List as List
129133import Data.Maybe (Maybe(..))
130134import Data.Time.Duration (Milliseconds(..))
131135import Data.Traversable (traverse, traverse_)
132−import Data.Tuple (Tuple(..))
136+import Data.Tuple.Nested (type (/\), (/\))
133137import Data.Variant (Variant)
134−import Effect.Aff (Aff, Error, delay, forkAff, joinFiber, killFiber)
138+import Effect.Aff (Aff, Error, delay, forkAff, killFiber)
135139import Effect.Aff.AVar as AVar
136140import Effect.Aff.Class (liftAff)
137141import Effect.Exception as Exception
138−import Effect.Now (now)
139142import Effect.Ref as Ref
140143import Effect.Class (liftEffect)
141144import Data.CatQueue as CatQueue
142145import Data.CatQueue (CatQueue)
143−import Data.DateTime.Instant (unInstant)
144146import Yoga.Om (Om)
145147import Yoga.Om as Om
146148
@@ -160,18 +162,18 @@ import Yoga.Om as Om
160162-- | - Loop (chunk, next) => emit chunk (if present) and continue with next
161163-- | - Done finalChunk => emit final chunk (if present) and stop
162164newtype Strom ctx err a = Strom
163− { pull :: Om ctx err (Step (Tuple (Maybe (Chunk a)) (Strom ctx err a)) (Maybe (Chunk a)))
165+ { pull :: Om ctx err (Step (((Maybe (Chunk a)) /\ (Strom ctx err a))) (Maybe (Chunk a)))
164166 }
165167
166168-- | Chunks for efficient batch processing
167169type Chunk a = Array a
168170
169171-- | Helper to create a Strom from a pull function
170−mkStrom :: forall ctx err a. Om ctx err (Step (Tuple (Maybe (Chunk a)) (Strom ctx err a)) (Maybe (Chunk a))) -> Strom ctx err a
172+mkStrom :: forall ctx err a. Om ctx err (Step (((Maybe (Chunk a)) /\ (Strom ctx err a))) (Maybe (Chunk a))) -> Strom ctx err a
171173mkStrom pull = Strom { pull }
172174
173175-- | Helper to unwrap a Strom
174−runStrom :: forall ctx err a. Strom ctx err a -> Om ctx err (Step (Tuple (Maybe (Chunk a)) (Strom ctx err a)) (Maybe (Chunk a)))
176+runStrom :: forall ctx err a. Strom ctx err a -> Om ctx err (Step (((Maybe (Chunk a)) /\ (Strom ctx err a))) (Maybe (Chunk a)))
175177runStrom (Strom s) = s.pull
176178
177179--------------------------------------------------------------------------------
@@ -181,22 +183,22 @@ runStrom (Strom s) = s.pull
181183-- | Stream identifier for tracking which stream produced data
182184data StreamId = StreamId1 | StreamId2
183185
184−derive instance eqStreamId :: Eq StreamId
185−derive instance ordStreamId :: Ord StreamId
186+derive instance Eq StreamId
187+derive instance Ord StreamId
186188
187189-- | Messages passed through the coordination queue
188190data QueueItem a
189191 = DataChunk (Array a) StreamId
190192 | StreamDone StreamId
191193
192−derive instance functorQueueItem :: Functor QueueItem
194+derive instance Functor QueueItem
193195
194196-- | Messages for mergeAllND buffered queue
195197data MergeAllItem a
196198 = MergeChunk (Array a)
197199 | MergeDone
198200
199−derive instance functorMergeAllItem :: Functor MergeAllItem
201+derive instance Functor MergeAllItem
200202
201203-- | Merge termination strategy (like ZStream HaltStrategy)
202204data HaltStrategy
@@ -205,14 +207,14 @@ data HaltStrategy
205207 | HaltLeft
206208 | HaltRight
207209
208−derive instance eqHaltStrategy :: Eq HaltStrategy
210+derive instance Eq HaltStrategy
209211
210212-- | Messages for buffering queues
211213data BufferItem a
212214 = BufferChunk (Array a)
213215 | BufferDone
214216
215−derive instance functorBufferItem :: Functor BufferItem
217+derive instance Functor BufferItem
216218
217219--------------------------------------------------------------------------------
218220-- Construction
@@ -268,7 +270,7 @@ rangeStrom start end = rangeHelper start end
268270 chunkEnd = min (current + chunkSize) limit
269271 chunk = Array.range current (chunkEnd - 1)
270272 if chunkEnd >= limit then pure $ Done $ Just chunk -- Final chunk
271− else pure $ Loop $ Tuple (Just chunk) (rangeHelper chunkEnd limit) -- Emit chunk and continue
273+ else pure $ Loop $ ((Just chunk) /\ (rangeHelper chunkEnd limit)) -- Emit chunk and continue
272274
273275-- | Iterate producing values - limited to ~10,000 elements for stack safety
274276-- | For truly infinite streams, use `iterateStromInfinite` (with Aff overhead)
@@ -306,7 +308,7 @@ iterateStromInfinite f initial = iterateHelper initial
306308 finalVal <- STRef.read valRef
307309 frozen <- STArray.unsafeFreeze arr
308310 pure { arr: frozen, nextVal: finalVal }
309− pure $ Loop $ Tuple (Just result.arr) (iterateHelper result.nextVal)
311+ pure $ Loop $ ((Just result.arr) /\ (iterateHelper result.nextVal))
310312
311313-- | Repeat a value - limited to ~10,000 elements for stack safety
312314repeatStrom :: forall ctx err a. a -> Strom ctx err a
@@ -321,7 +323,7 @@ repeatStromInfinite a = repeatHelper unit
321323 -- Async boundary resets the stack!
322324 liftAff $ delay (Milliseconds 0.0)
323325 let chunk = Array.replicate 10000 a
324− pure $ Loop $ Tuple (Just chunk) (repeatHelper unit)
326+ pure $ Loop $ ((Just chunk) /\ (repeatHelper unit))
325327
326328-- | Repeat an Om computation - limited to 100 iterations for stack safety
327329-- | For truly infinite repetition, use `repeatOmStromInfinite` (with Aff overhead)
@@ -333,7 +335,7 @@ repeatOmStrom om = repeatOmHelper 0
333335 | count >= maxCount = empty
334336 | otherwise = mkStrom do
335337 value <- om
336− pure $ Loop $ Tuple (Just [ value ]) (repeatOmHelper (count + 1))
338+ pure $ Loop $ ((Just [ value ]) /\ (repeatOmHelper (count + 1)))
337339
338340-- | Truly infinite Om repetition - stack-safe via Aff async boundaries
339341-- | Adds tiny delay (0ms) at each pull to reset the stack
@@ -344,11 +346,11 @@ repeatOmStromInfinite om = repeatOmHelper unit
344346 -- Async boundary resets the stack!
345347 liftAff $ delay (Milliseconds 0.0)
346348 value <- om
347− pure $ Loop $ Tuple (Just [ value ]) (repeatOmHelper unit)
349+ pure $ Loop $ ((Just [ value ]) /\ (repeatOmHelper unit))
348350
349351-- | Unfold a stream from a seed value - can be infinite if f never returns Nothing
350352-- | Emits chunks lazily, terminates when f returns Nothing
351−unfoldStrom :: forall ctx err a b. (b -> Maybe (Tuple a b)) -> b -> Strom ctx err a
353+unfoldStrom :: forall ctx err a b. (b -> Maybe ((a /\ b))) -> b -> Strom ctx err a
352354unfoldStrom f seed = unfoldHelper seed
353355 where
354356 unfoldHelper currentSeed = mkStrom do
@@ -357,7 +359,7 @@ unfoldStrom f seed = unfoldHelper seed
357359 Nothing -> pure $ Done $ if Array.null chunk.values then Nothing else Just chunk.values
358360 Just nextSeed ->
359361 if Array.null chunk.values then runStrom (unfoldHelper nextSeed)
360− else pure $ Loop $ Tuple (Just chunk.values) (unfoldHelper nextSeed)
362+ else pure $ Loop $ ((Just chunk.values) /\ (unfoldHelper nextSeed))
361363
362364 buildChunk seed' size = ST.run do
363365 arr <- STArray.unsafeThaw []
@@ -370,7 +372,7 @@ unfoldStrom f seed = unfoldHelper seed
370372 s <- STRef.read seedRef
371373 case f s of
372374 Nothing -> void $ STRef.write true stoppedRef
373− Just (Tuple value nextS) -> do
375+ Just ((value /\ nextS)) -> do
374376 void $ STArray.push value arr
375377 void $ STRef.write nextS seedRef
376378 frozen <- STArray.unsafeFreeze arr
@@ -379,14 +381,14 @@ unfoldStrom f seed = unfoldHelper seed
379381 pure { values: frozen, finalSeed: if stopped then Nothing else Just finalSeed }
380382
381383-- | Unfold a stream with an effectful function
382−unfoldOmStrom :: forall ctx err a b. (b -> Om ctx err (Maybe (Tuple a b))) -> b -> Strom ctx err a
384+unfoldOmStrom :: forall ctx err a b. (b -> Om ctx err (Maybe ((a /\ b)))) -> b -> Strom ctx err a
383385unfoldOmStrom f seed = mkStrom go
384386 where
385387 go = do
386388 result <- f seed
387389 case result of
388390 Nothing -> pure $ Done Nothing
389− Just (Tuple value nextSeed) -> pure $ Loop $ Tuple (Just [ value ]) (unfoldOmStrom f nextSeed)
391+ Just ((value /\ nextSeed)) -> pure $ Loop $ ((Just [ value ]) /\ (unfoldOmStrom f nextSeed))
390392
391393--------------------------------------------------------------------------------
392394-- Transformations
@@ -399,7 +401,7 @@ mapStrom f stream = mkStrom do
399401 case step of
400402 Done Nothing -> pure $ Done Nothing
401403 Done (Just chunk) -> pure $ Done $ Just $ Functor.map f chunk
402− Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple (Functor.map (Functor.map f) maybeChunk) (mapStrom f next)
404+ Loop ((maybeChunk /\ next)) -> pure $ Loop $ ((Functor.map (Functor.map f) maybeChunk) /\ (mapStrom f next))
403405
404406-- | Map with a monadic effect
405407mapMStrom :: forall ctx err a b. (a -> Om ctx err b) -> Strom ctx err a -> Strom ctx err b
@@ -410,11 +412,11 @@ mapMStrom f stream = mkStrom do
410412 Done (Just chunk) -> do
411413 mapped <- traverse f chunk
412414 pure $ Done $ Just mapped
413− Loop (Tuple maybeChunk next) -> do
415+ Loop ((maybeChunk /\ next)) -> do
414416 mappedChunk <- case maybeChunk of
415417 Nothing -> pure Nothing
416418 Just chunk -> Just <$> traverse f chunk
417− pure $ Loop $ Tuple mappedChunk (mapMStrom f next)
419+ pure $ Loop $ (mappedChunk /\ (mapMStrom f next))
418420
419421-- | Tap (observe without modifying)
420422tapStrom :: forall ctx err a. (a -> Unit) -> Strom ctx err a -> Strom ctx err a
@@ -425,16 +427,15 @@ tapStrom f stream = mkStrom do
425427 Done (Just chunk) -> do
426428 let _ = Functor.map f chunk
427429 pure $ Done $ Just chunk
428− Loop (Tuple maybeChunk next) -> do
430+ Loop ((maybeChunk /\ next)) -> do
429431 let
430432 tappedChunk = case maybeChunk of
431433 Nothing -> Nothing
432434 Just chunk ->
433− let
434− _ = Functor.map f chunk
435− in
435+ do
436+ let _ = Functor.map f chunk
436437 Just chunk
437− pure $ Loop $ Tuple tappedChunk (tapStrom f next)
438+ pure $ Loop $ (tappedChunk /\ (tapStrom f next))
438439
439440-- | Tap with monadic effect (observe with effects)
440441tapMStrom :: forall ctx err a. (a -> Om ctx err Unit) -> Strom ctx err a -> Strom ctx err a
@@ -445,20 +446,20 @@ tapMStrom f stream = mkStrom do
445446 Done (Just chunk) -> do
446447 _ <- traverse f chunk
447448 pure $ Done $ Just chunk
448− Loop (Tuple maybeChunk next) -> do
449+ Loop ((maybeChunk /\ next)) -> do
449450 tappedChunk <- case maybeChunk of
450451 Nothing -> pure Nothing
451452 Just chunk -> do
452453 _ <- traverse f chunk
453454 pure $ Just chunk
454− pure $ Loop $ Tuple tappedChunk (tapMStrom f next)
455+ pure $ Loop $ (tappedChunk /\ (tapMStrom f next))
455456
456457-- | Bind for streams (monadic flatMap/chain)
457458-- | Use >>= operator for cleaner syntax: `stream >>= f`
458459bindStrom :: forall ctx err a b. (a -> Strom ctx err b) -> Strom ctx err a -> Strom ctx err b
459460bindStrom f stream = stream >>= f
460461
461−-- | Scan with accumulator (like foldl but emits intermediate results)
462+-- | Scan with accumulator (like foldl but emits intermediate results)
462463scanStrom :: forall ctx err a b. (b -> a -> b) -> b -> Strom ctx err a -> Strom ctx err b
463464scanStrom f initial stream = scanHelper initial stream
464465 where
@@ -469,35 +470,33 @@ scanStrom f initial stream = scanHelper initial stream
469470 Done (Just chunk) -> do
470471 -- Build results by folding and collecting intermediate values
471472 let
472− Tuple _ results = Array.foldl
473− ( \(Tuple prevAcc accList) item ->
474− let
475− newAcc = f prevAcc item
476− in
477− Tuple newAcc (Array.snoc accList newAcc)
473+ (_ /\ results) = Array.foldl
474+ ( \((prevAcc /\ accList)) item ->
475+ do
476+ let newAcc = f prevAcc item
477+ (newAcc /\ (Array.snoc accList newAcc))
478478 )
479− (Tuple acc [])
479+ (acc /\ [])
480480 chunk
481481 pure $ Done $ if Array.null results then Nothing else Just results
482− Loop (Tuple maybeChunk next) ->
482+ Loop ((maybeChunk /\ next)) ->
483483 case maybeChunk of
484− Nothing -> pure $ Loop $ Tuple Nothing (scanHelper acc next)
484+ Nothing -> pure $ Loop $ (Nothing /\ (scanHelper acc next))
485485 Just chunk -> do
486486 let
487− Tuple newAcc results = Array.foldl
488− ( \(Tuple prevAcc accList) item ->
489− let
490− currAcc = f prevAcc item
491− in
492− Tuple currAcc (Array.snoc accList currAcc)
487+ (newAcc /\ results) = Array.foldl
488+ ( \((prevAcc /\ accList)) item ->
489+ do
490+ let currAcc = f prevAcc item
491+ (currAcc /\ (Array.snoc accList currAcc))
493492 )
494− (Tuple acc [])
493+ (acc /\ [])
495494 chunk
496− if Array.null results then pure $ Loop $ Tuple Nothing (scanHelper newAcc next)
497− else pure $ Loop $ Tuple (Just results) (scanHelper newAcc next)
495+ if Array.null results then pure $ Loop $ (Nothing /\ (scanHelper newAcc next))
496+ else pure $ Loop $ ((Just results) /\ (scanHelper newAcc next))
498497
499498-- | Map with accumulator (stateful map)
500−mapAccumStrom :: forall ctx err a b s. (s -> a -> Tuple s b) -> s -> Strom ctx err a -> Strom ctx err b
499+mapAccumStrom :: forall ctx err a b s. (s -> a -> (s /\ b)) -> s -> Strom ctx err a -> Strom ctx err b
501500mapAccumStrom f initial stream = mapAccumHelper initial stream
502501 where
503502 mapAccumHelper state s = mkStrom do
@@ -506,32 +505,30 @@ mapAccumStrom f initial stream = mapAccumHelper initial stream
506505 Done Nothing -> pure $ Done Nothing
507506 Done (Just chunk) -> do
508507 let
509− Tuple finalState results = Array.foldl
510− ( \(Tuple st acc) a ->
511− let
512− Tuple newSt b = f st a
513− in
514− Tuple newSt (Array.snoc acc b)
508+ (_ /\ results) = Array.foldl
509+ ( \((st /\ acc)) a ->
510+ do
511+ let (newSt /\ b) = f st a
512+ (newSt /\ (Array.snoc acc b))
515513 )
516− (Tuple state [])
514+ (state /\ [])
517515 chunk
518516 pure $ Done $ if Array.null results then Nothing else Just results
519− Loop (Tuple maybeChunk next) ->
517+ Loop ((maybeChunk /\ next)) ->
520518 case maybeChunk of
521− Nothing -> pure $ Loop $ Tuple Nothing (mapAccumHelper state next)
519+ Nothing -> pure $ Loop $ (Nothing /\ (mapAccumHelper state next))
522520 Just chunk -> do
523521 let
524− Tuple newState results = Array.foldl
525− ( \(Tuple st acc) a ->
526− let
527− Tuple newSt b = f st a
528− in
529− Tuple newSt (Array.snoc acc b)
522+ (newState /\ results) = Array.foldl
523+ ( \((st /\ acc)) a ->
524+ do
525+ let (newSt /\ b) = f st a
526+ (newSt /\ (Array.snoc acc b))
530527 )
531− (Tuple state [])
528+ (state /\ [])
532529 chunk
533− if Array.null results then pure $ Loop $ Tuple Nothing (mapAccumHelper newState next)
534− else pure $ Loop $ Tuple (Just results) (mapAccumHelper newState next)
530+ if Array.null results then pure $ Loop $ (Nothing /\ (mapAccumHelper newState next))
531+ else pure $ Loop $ ((Just results) /\ (mapAccumHelper newState next))
535532
536533-- | Filter elements
537534filterStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
@@ -542,16 +539,15 @@ filterStrom predicate stream = mkStrom do
542539 Done (Just chunk) -> do
543540 let filtered = Array.filter predicate chunk
544541 pure $ Done $ if Array.null filtered then Nothing else Just filtered
545− Loop (Tuple maybeChunk next) -> do
542+ Loop ((maybeChunk /\ next)) -> do
546543 let
547544 filteredChunk = case maybeChunk of
548545 Nothing -> Nothing
549546 Just chunk ->
550− let
551− filtered = Array.filter predicate chunk
552− in
547+ do
548+ let filtered = Array.filter predicate chunk
553549 if Array.null filtered then Nothing else Just filtered
554− pure $ Loop $ Tuple filteredChunk (filterStrom predicate next)
550+ pure $ Loop $ (filteredChunk /\ (filterStrom predicate next))
555551
556552--------------------------------------------------------------------------------
557553-- Selection
@@ -573,18 +569,17 @@ takeStrom n stream = takeHelper n stream
573569 numTaken = Array.length taken
574570 if numTaken == 0 then pure $ Done Nothing
575571 else pure $ Done $ Just taken
576− Loop (Tuple maybeChunk next) ->
572+ Loop ((maybeChunk /\ next)) ->
577573 case maybeChunk of
578− Nothing -> pure $ Loop $ Tuple Nothing (takeHelper remaining next)
574+ Nothing -> pure $ Loop $ (Nothing /\ (takeHelper remaining next))
579575 Just chunk -> do
580576 let
581− chunkLen = Array.length chunk
582577 taken = Array.take remaining chunk
583578 numTaken = Array.length taken
584579 newRemaining = remaining - numTaken
585580 if numTaken == 0 then pure $ Done Nothing
586581 else if newRemaining <= 0 then pure $ Done $ Just taken
587− else pure $ Loop $ Tuple (Just taken) (takeHelper newRemaining next)
582+ else pure $ Loop $ ((Just taken) /\ (takeHelper newRemaining next))
588583
589584-- | Take while predicate is true
590585takeWhileStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
@@ -596,7 +591,7 @@ takeWhileStrom predicate stream = mkStrom do
596591 let taken = Array.takeWhile predicate chunk
597592 if Array.length taken < Array.length chunk then pure $ Done $ Just taken
598593 else pure $ Done $ Just taken
599− Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (takeWhileStrom predicate next)
594+ Loop ((maybeChunk /\ next)) -> pure $ Loop $ (maybeChunk /\ (takeWhileStrom predicate next))
600595
601596-- | Drop n elements (with proper state tracking across chunks)
602597dropStrom :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a
@@ -613,19 +608,17 @@ dropStrom n stream
613608 Done (Just chunk) -> do
614609 let dropped = Array.drop remaining chunk
615610 pure $ Done $ if Array.null dropped then Nothing else Just dropped
616− Loop (Tuple maybeChunk next) ->
611+ Loop ((maybeChunk /\ next)) ->
617612 case maybeChunk of
618− Nothing -> pure $ Loop $ Tuple Nothing (dropHelper remaining next)
613+ Nothing -> pure $ Loop $ (Nothing /\ (dropHelper remaining next))
619614 Just chunk ->
620− let
621− chunkLen = Array.length chunk
622− in
623− if remaining >= chunkLen then pure $ Loop $ Tuple Nothing (dropHelper (remaining - chunkLen) next)
615+ do
616+ let chunkLen = Array.length chunk
617+ if remaining >= chunkLen then pure $ Loop $ (Nothing /\ (dropHelper (remaining - chunkLen) next))
624618 else
625− let
626− dropped = Array.drop remaining chunk
627− in
628− pure $ Loop $ Tuple (Just dropped) next
619+ do
620+ let dropped = Array.drop remaining chunk
621+ pure $ Loop $ ((Just dropped) /\ next)
629622
630623-- | Take until predicate is true (includes the matching element)
631624takeUntilStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
@@ -637,12 +630,12 @@ takeUntilStrom predicate stream = mkStrom do
637630 case Array.findIndex predicate chunk of
638631 Just idx -> pure $ Done $ Just $ Array.take (idx + 1) chunk -- Include the matching element
639632 Nothing -> pure $ Done $ Just chunk
640− Loop (Tuple maybeChunk next) ->
633+ Loop ((maybeChunk /\ next)) ->
641634 case maybeChunk of
642− Nothing -> pure $ Loop $ Tuple Nothing (takeUntilStrom predicate next)
635+ Nothing -> pure $ Loop $ (Nothing /\ (takeUntilStrom predicate next))
643636 Just chunk -> case Array.findIndex predicate chunk of
644637 Just idx -> pure $ Done $ Just $ Array.take (idx + 1) chunk -- Found match, stop here
645− Nothing -> pure $ Loop $ Tuple (Just chunk) (takeUntilStrom predicate next) -- Keep going
638+ Nothing -> pure $ Loop $ ((Just chunk) /\ (takeUntilStrom predicate next)) -- Keep going
646639
647640-- | Drop while predicate is true
648641dropWhileStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
@@ -657,13 +650,13 @@ dropWhileStrom predicate stream = dropWhileHelper true stream
657650 Done (Just chunk) -> do
658651 let dropped = Array.dropWhile predicate chunk
659652 pure $ Done $ if Array.null dropped then Nothing else Just dropped
660− Loop (Tuple maybeChunk next) ->
653+ Loop ((maybeChunk /\ next)) ->
661654 case maybeChunk of
662− Nothing -> pure $ Loop $ Tuple Nothing (dropWhileHelper stillDropping next)
655+ Nothing -> pure $ Loop $ (Nothing /\ (dropWhileHelper stillDropping next))
663656 Just chunk -> do
664657 let dropped = Array.dropWhile predicate chunk
665− if Array.null dropped then pure $ Loop $ Tuple Nothing (dropWhileHelper true next)
666− else pure $ Loop $ Tuple (Just dropped) next -- Found elements to keep, stop dropping
658+ if Array.null dropped then pure $ Loop $ (Nothing /\ (dropWhileHelper true next))
659+ else pure $ Loop $ ((Just dropped) /\ next) -- Found elements to keep, stop dropping
667660
668661-- | Filter with monadic predicate
669662filterMStrom :: forall ctx err a. (a -> Om ctx err Boolean) -> Strom ctx err a -> Strom ctx err a
@@ -674,13 +667,13 @@ filterMStrom predicate stream = mkStrom do
674667 Done (Just chunk) -> do
675668 filtered <- Array.filterA predicate chunk
676669 pure $ Done $ if Array.null filtered then Nothing else Just filtered
677− Loop (Tuple maybeChunk next) -> do
670+ Loop ((maybeChunk /\ next)) -> do
678671 filteredChunk <- case maybeChunk of
679672 Nothing -> pure Nothing
680673 Just chunk -> do
681674 filtered <- Array.filterA predicate chunk
682675 pure $ if Array.null filtered then Nothing else Just filtered
683− pure $ Loop $ Tuple filteredChunk (filterMStrom predicate next)
676+ pure $ Loop $ (filteredChunk /\ (filterMStrom predicate next))
684677
685678-- | Collect with partial function (mapMaybe)
686679collectStrom :: forall ctx err a b. (a -> Maybe b) -> Strom ctx err a -> Strom ctx err b
@@ -691,17 +684,16 @@ collectStrom f stream = mkStrom do
691684 Done (Just chunk) -> do
692685 let collected = Array.mapMaybe f chunk
693686 pure $ Done $ if Array.null collected then Nothing else Just collected
694− Loop (Tuple maybeChunk next) ->
695− let
696− collected = case maybeChunk of
697− Nothing -> Nothing
698− Just chunk ->
699− let
700− c = Array.mapMaybe f chunk
701− in
687+ Loop ((maybeChunk /\ next)) ->
688+ do
689+ let
690+ collected = case maybeChunk of
691+ Nothing -> Nothing
692+ Just chunk -> do
693+ let c = Array.mapMaybe f chunk
702694 if Array.null c then Nothing else Just c
703− in
704− pure $ Loop $ Tuple collected (collectStrom f next)
695+
696+ pure $ Loop $ (collected /\ (collectStrom f next))
705697
706698-- | Collect with monadic partial function
707699collectMStrom :: forall ctx err a b. (a -> Om ctx err (Maybe b)) -> Strom ctx err a -> Strom ctx err b
@@ -713,16 +705,16 @@ collectMStrom f stream = mkStrom do
713705 maybes <- traverse f chunk
714706 let collected = Array.mapMaybe identity maybes
715707 pure $ Done $ if Array.null collected then Nothing else Just collected
716− Loop (Tuple maybeChunk next) -> do
708+ Loop ((maybeChunk /\ next)) -> do
717709 collected <- case maybeChunk of
718710 Nothing -> pure Nothing
719711 Just chunk -> do
720712 maybes <- traverse f chunk
721713 let c = Array.mapMaybe identity maybes
722714 pure $ if Array.null c then Nothing else Just c
723− pure $ Loop $ Tuple collected (collectMStrom f next)
715+ pure $ Loop $ (collected /\ (collectMStrom f next))
724716
725−-- | Remove consecutive duplicates
717+-- | Remove consecutive duplicates
726718changesStrom :: forall ctx err a. Eq a => Strom ctx err a -> Strom ctx err a
727719changesStrom stream = changesHelper Nothing stream
728720 where
@@ -732,28 +724,28 @@ changesStrom stream = changesHelper Nothing stream
732724 Done Nothing -> pure $ Done Nothing
733725 Done (Just chunk) -> do
734726 let
735− Tuple _ result = Array.foldl
736− ( \(Tuple prev acc) curr ->
737− if Just curr == prev then Tuple (Just curr) acc
738− else Tuple (Just curr) (Array.snoc acc curr)
727+ (_ /\ result) = Array.foldl
728+ ( \((prev /\ acc)) curr ->
729+ if Just curr == prev then ((Just curr) /\ acc)
730+ else ((Just curr) /\ (Array.snoc acc curr))
739731 )
740− (Tuple lastSeen [])
732+ (lastSeen /\ [])
741733 chunk
742734 pure $ Done $ if Array.null result then Nothing else Just result
743− Loop (Tuple maybeChunk next) ->
735+ Loop ((maybeChunk /\ next)) ->
744736 case maybeChunk of
745− Nothing -> pure $ Loop $ Tuple Nothing (changesHelper lastSeen next)
737+ Nothing -> pure $ Loop $ (Nothing /\ (changesHelper lastSeen next))
746738 Just chunk -> do
747739 let
748− Tuple newLast result = Array.foldl
749− ( \(Tuple prev acc) curr ->
750− if Just curr == prev then Tuple (Just curr) acc
751− else Tuple (Just curr) (Array.snoc acc curr)
740+ (newLast /\ result) = Array.foldl
741+ ( \((prev /\ acc)) curr ->
742+ if Just curr == prev then ((Just curr) /\ acc)
743+ else ((Just curr) /\ (Array.snoc acc curr))
752744 )
753− (Tuple lastSeen [])
745+ (lastSeen /\ [])
754746 chunk
755− if Array.null result then pure $ Loop $ Tuple Nothing (changesHelper newLast next)
756− else pure $ Loop $ Tuple (Just result) (changesHelper newLast next)
747+ if Array.null result then pure $ Loop $ (Nothing /\ (changesHelper newLast next))
748+ else pure $ Loop $ ((Just result) /\ (changesHelper newLast next))
757749
758750--------------------------------------------------------------------------------
759751-- Grouping
@@ -765,16 +757,16 @@ groupedStrom size stream
765757 | size <= 0 = empty
766758 | otherwise = groupedHelper [] stream
767759 where
768− groupedHelper buffer s = mkStrom do
760+ groupedHelper buf s = mkStrom do
769761 step <- runStrom s
770762 case step of
771763 Done Nothing ->
772764 -- End of stream, emit remaining buffer if non-empty
773− pure $ Done $ if Array.null buffer then Nothing else Just [ buffer ]
765+ pure $ Done $ if Array.null buf then Nothing else Just [ buf ]
774766 Done (Just chunk) -> do
775767 -- Process final chunk with buffer
776768 let
777− combined = buffer <> chunk
769+ combined = buf <> chunk
778770 groups = groupsOf size combined
779771 completeGroups = Array.dropEnd 1 groups
780772 lastGroup = case Array.last groups of
@@ -785,28 +777,28 @@ groupedStrom size stream
785777 Nothing -> []
786778 if Array.null remainder then pure $ Done $ if Array.null (completeGroups <> lastGroup) then Nothing else Just (completeGroups <> lastGroup)
787779 else pure $ Done $ if Array.null (completeGroups <> [ remainder ]) then Nothing else Just (completeGroups <> [ remainder ])
788− Loop (Tuple maybeChunk next) ->
780+ Loop ((maybeChunk /\ next)) ->
789781 case maybeChunk of
790− Nothing -> pure $ Loop $ Tuple Nothing (groupedHelper buffer next)
782+ Nothing -> pure $ Loop $ (Nothing /\ (groupedHelper buf next))
791783 Just chunk -> do
792784 let
793− combined = buffer <> chunk
785+ combined = buf <> chunk
794786 groups = groupsOf size combined
795787 completeGroups = if Array.null groups then [] else Array.dropEnd 1 groups
796788 newBuffer = case Array.last groups of
797789 Just g -> if Array.length g < size then g else []
798790 Nothing -> []
799− if Array.null completeGroups then pure $ Loop $ Tuple Nothing (groupedHelper newBuffer next)
800− else pure $ Loop $ Tuple (Just completeGroups) (groupedHelper newBuffer next)
791+ if Array.null completeGroups then pure $ Loop $ (Nothing /\ (groupedHelper newBuffer next))
792+ else pure $ Loop $ ((Just completeGroups) /\ (groupedHelper newBuffer next))
801793
802794 groupsOf :: forall b. Int -> Array b -> Array (Array b)
803795 groupsOf n arr
804796 | n <= 0 || Array.null arr = []
805797 | otherwise =
806− let
807− group = Array.take n arr
808− rest = Array.drop n arr
809− in
798+ do
799+ let
800+ group = Array.take n arr
801+ rest = Array.drop n arr
810802 if Array.null group then [] else Array.cons group (groupsOf n rest)
811803
812804-- | Alias for groupedStrom
@@ -818,74 +810,70 @@ chunkedStrom = groupedStrom
818810groupByStrom :: forall ctx err a k. Eq k => (a -> k) -> Strom ctx err a -> Strom ctx err (Array a)
819811groupByStrom keyFn stream = groupByHelper Nothing [] stream
820812 where
821− groupByHelper lastKey buffer s = mkStrom do
813+ groupByHelper lastKey buf s = mkStrom do
822814 step <- runStrom s
823815 case step of
824816 Done Nothing ->
825− pure $ Done $ if Array.null buffer then Nothing else Just [ buffer ]
817+ pure $ Done $ if Array.null buf then Nothing else Just [ buf ]
826818 Done (Just chunk) -> do
827819 let
828− Tuple _ result = Array.foldl
829− ( \(Tuple currentKey acc) item ->
830− let
831− itemKey = keyFn item
832− in
820+ (_ /\ result) = Array.foldl
821+ ( \((currentKey /\ acc)) item ->
822+ do
823+ let itemKey = keyFn item
833824 case currentKey of
834− Nothing -> Tuple (Just itemKey) { groups: [], buffer: [ item ] }
825+ Nothing -> ((Just itemKey) /\ { groups: [], buffer: [ item ] })
835826 Just k ->
836− if k == itemKey then Tuple (Just itemKey) { groups: acc.groups, buffer: Array.snoc acc.buffer item }
837− else Tuple (Just itemKey) { groups: Array.snoc acc.groups acc.buffer, buffer: [ item ] }
827+ if k == itemKey then ((Just itemKey) /\ { groups: acc.groups, buffer: Array.snoc acc.buffer item })
828+ else ((Just itemKey) /\ { groups: Array.snoc acc.groups acc.buffer, buffer: [ item ] })
838829 )
839− (Tuple lastKey { groups: [], buffer })
830+ (lastKey /\ { groups: [], buffer: buf })
840831 chunk
841832 allGroups =
842833 if Array.null result.buffer then result.groups
843834 else Array.snoc result.groups result.buffer
844835 pure $ Done $ if Array.null allGroups then Nothing else Just allGroups
845− Loop (Tuple maybeChunk next) ->
836+ Loop ((maybeChunk /\ next)) ->
846837 case maybeChunk of
847− Nothing -> pure $ Loop $ Tuple Nothing (groupByHelper lastKey buffer next)
838+ Nothing -> pure $ Loop $ (Nothing /\ (groupByHelper lastKey buf next))
848839 Just chunk -> do
849840 let
850− Tuple newKey result = Array.foldl
851− ( \(Tuple currentKey acc) item ->
852− let
853− itemKey = keyFn item
854− in
841+ (newKey /\ result) = Array.foldl
842+ ( \((currentKey /\ acc)) item ->
843+ do
844+ let itemKey = keyFn item
855845 case currentKey of
856− Nothing -> Tuple (Just itemKey) { groups: [], buffer: [ item ] }
846+ Nothing -> ((Just itemKey) /\ { groups: [], buffer: [ item ] })
857847 Just k ->
858− if k == itemKey then Tuple (Just itemKey) { groups: acc.groups, buffer: Array.snoc acc.buffer item }
859− else Tuple (Just itemKey) { groups: Array.snoc acc.groups acc.buffer, buffer: [ item ] }
848+ if k == itemKey then ((Just itemKey) /\ { groups: acc.groups, buffer: Array.snoc acc.buffer item })
849+ else ((Just itemKey) /\ { groups: Array.snoc acc.groups acc.buffer, buffer: [ item ] })
860850 )
861− (Tuple lastKey { groups: [], buffer })
851+ (lastKey /\ { groups: [], buffer: buf })
862852 chunk
863− if Array.null result.groups then pure $ Loop $ Tuple Nothing (groupByHelper newKey result.buffer next)
864− else pure $ Loop $ Tuple (Just result.groups) (groupByHelper newKey result.buffer next)
853+ if Array.null result.groups then pure $ Loop $ (Nothing /\ (groupByHelper newKey result.buffer next))
854+ else pure $ Loop $ ((Just result.groups) /\ (groupByHelper newKey result.buffer next))
865855
866856-- | Partition a stream by a predicate into two separate operations
867857-- | Returns a tuple of (trues, falses)
868858-- | Note: This requires running the stream twice, so it's not lazy
869−partition :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Tuple (Strom ctx err a) (Strom ctx err a)
859+partition :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> ((Strom ctx err a) /\ (Strom ctx err a))
870860partition predicate stream =
871− Tuple
872− (filterStrom predicate stream)
873− (filterStrom (not <<< predicate) stream)
861+ (filterStrom predicate stream)
862+ /\ (filterStrom (not <<< predicate) stream)
874863
875864-- | Partition and map in one pass
876−partitionMap :: forall ctx err a b c. (a -> Either b c) -> Strom ctx err a -> Tuple (Strom ctx err b) (Strom ctx err c)
865+partitionMap :: forall ctx err a b c. (a -> Either b c) -> Strom ctx err a -> ((Strom ctx err b) /\ (Strom ctx err c))
877866partitionMap f stream =
878− let
879− leftOnly x = case f x of
880− Left b -> Just b
881− Right _ -> Nothing
882− rightOnly x = case f x of
883− Left _ -> Nothing
884− Right c -> Just c
885− in
886− Tuple
887− (collectStrom leftOnly stream)
888− (collectStrom rightOnly stream)
867+ do
868+ let
869+ leftOnly x = case f x of
870+ Left b -> Just b
871+ Right _ -> Nothing
872+ rightOnly x = case f x of
873+ Left _ -> Nothing
874+ Right c -> Just c
875+ (collectStrom leftOnly stream)
876+ /\ (collectStrom rightOnly stream)
889877
890878--------------------------------------------------------------------------------
891879-- Timing
@@ -901,49 +889,24 @@ debounce duration stream = mkStrom do
901889 Done (Just chunk) -> do
902890 Om.delay duration
903891 pure $ Done $ Just chunk
904− Loop (Tuple maybeChunk next) -> do
892+ Loop (maybeChunk /\ next) -> do
905893 case maybeChunk of
906− Nothing -> pure $ Loop $ Tuple Nothing (debounce duration next)
894+ Nothing -> pure $ Loop $ (Nothing /\ (debounce duration next))
907895 Just chunk -> do
908896 Om.delay duration
909− pure $ Loop $ Tuple (Just chunk) (debounce duration next)
897+ pure $ Loop $ ((Just chunk) /\ (debounce duration next))
910898
911−-- | Throttle a stream - emit at most once per duration period
912−-- | Tracks last emission time and skips elements that arrive too quickly
913−-- | Note: Uses wall-clock time, not processing time
899+-- | Debounce a stream per element - adds a delay after each individual element
900+debounceEach :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
901+debounceEach duration stream = stream # bindStrom \a -> mkStrom do
902+ Om.delay duration
903+ pure $ Done $ Just [ a ]
904+
905+-- | Throttle a stream - emits at most one element per duration period
914906throttle :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
915−throttle (Milliseconds duration) stream = throttleWithState 0.0 stream
916− where
917− throttleWithState lastEmit s = mkStrom do
918− step <- runStrom s
919− case step of
920− Done Nothing -> pure $ Done Nothing
921− Done (Just chunk) -> do
922− Tuple newLastEmit filtered <- filterByTime lastEmit chunk
923− pure $ Done $ if Array.null filtered then Nothing else Just filtered
924− Loop (Tuple maybeChunk next) -> do
925− case maybeChunk of
926− Nothing -> pure $ Loop $ Tuple Nothing (throttleWithState lastEmit next)
927− Just chunk -> do
928− Tuple newLastEmit filtered <- filterByTime lastEmit chunk
929− if Array.null filtered then pure $ Loop $ Tuple Nothing (throttleWithState newLastEmit next)
930− else pure $ Loop $ Tuple (Just filtered) (throttleWithState newLastEmit next)
931−
932− filterByTime lastEmit chunk = do
933− Tuple newLastEmit results <- Array.foldl
934− ( \acc value -> do
935− Tuple currentLast filtered <- acc
936− currentTime <- liftEffect now
937− let Milliseconds currentMillis = unInstant currentTime
938− let elapsed = currentMillis - currentLast
939− if elapsed >= duration then
940− pure $ Tuple currentMillis (Array.snoc filtered value)
941− else
942− pure $ Tuple currentLast filtered
943− )
944− (pure $ Tuple lastEmit [])
945− chunk
946− pure $ Tuple newLastEmit results
907+throttle duration stream = stream # bindStrom \a -> mkStrom do
908+ Om.delay duration
909+ pure $ Done $ Just [ a ]
947910
948911-- | Delay each element by a duration
949912delayStrom :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
@@ -957,33 +920,33 @@ delayStrom duration stream = mkStrom do
957920
958921-- | Fold over all elements
959922runFold :: forall ctx err a b. b -> (b -> a -> b) -> Strom ctx err a -> Om ctx err b
960−runFold initial f stream = tailRecM go (Tuple initial stream)
923+runFold initial f stream = tailRecM go ((initial /\ stream))
961924 where
962− go (Tuple acc s) = do
925+ go ((acc /\ s)) = do
963926 step <- runStrom s
964927 case step of
965928 Done Nothing -> pure $ Done acc
966929 Done (Just chunk) -> pure $ Done $ foldl f acc chunk
967− Loop (Tuple maybeChunk next) ->
930+ Loop ((maybeChunk /\ next)) ->
968931 -- Process chunk if present and continue
969932 case maybeChunk of
970− Nothing -> pure $ Loop $ Tuple acc next
971− Just chunk -> pure $ Loop $ Tuple (foldl f acc chunk) next
933+ Nothing -> pure $ Loop $ (acc /\ next)
934+ Just chunk -> pure $ Loop $ ((foldl f acc chunk) /\ next)
972935
973936-- | Collect all elements into an array (O(n) using chunk accumulation)
974937runCollect :: forall ctx err a. Strom ctx err a -> Om ctx err (Array a)
975−runCollect stream = tailRecM go (Tuple List.Nil stream)
938+runCollect stream = tailRecM go (List.Nil /\ stream)
976939 where
977− go (Tuple chunkList s) = do
940+ go ((chunkList /\ s)) = do
978941 step <- runStrom s
979942 case step of
980943 Done Nothing -> pure $ Done $ Array.concat $ Array.fromFoldable $ List.reverse chunkList
981944 Done (Just chunk) -> pure $ Done $ Array.concat $ Array.fromFoldable $ List.reverse $ List.Cons chunk chunkList
982− Loop (Tuple maybeChunk next) ->
945+ Loop ((maybeChunk /\ next)) ->
983946 -- Emit the chunk (if present) and continue
984947 case maybeChunk of
985− Nothing -> pure $ Loop $ Tuple chunkList next
986− Just chunk -> pure $ Loop $ Tuple (List.Cons chunk chunkList) next
948+ Nothing -> pure $ Loop $ (chunkList /\ next)
949+ Just chunk -> pure $ Loop $ ((List.Cons chunk chunkList) /\ next)
987950
988951-- | Run the stream and discard all elements
989952runDrain :: forall ctx err a. Strom ctx err a -> Om ctx err Unit
@@ -1000,7 +963,7 @@ traverseStrom_ f stream = tailRecM go stream
1000963 Done (Just chunk) -> do
1001964 _ <- traverse f chunk
1002965 pure $ Done unit
1003− Loop (Tuple maybeChunk next) -> do
966+ Loop ((maybeChunk /\ next)) -> do
1004967 -- Process chunk if present, then continue
1005968 case maybeChunk of
1006969 Nothing -> pure unit
@@ -1024,20 +987,20 @@ traverseMStrom_ = traverseStrom_
1024987--------------------------------------------------------------------------------
1025988
1026989-- | Run a stream in the background, returning a Fiber that can be cancelled
1027−-- |
990+-- |
1028991-- | To use cancellable streams, convert your stream to Om and use Om.launchOm:
1029−-- |
992+-- |
1030993-- | Example:
1031994-- | ```purescript
1032995-- | fiber <- Om.launchOm ctx handlers (Strom.runCollect myStream)
1033996-- | -- ... later ...
1034997-- | killFiber (error "Cancelled") fiber
1035998-- | ```
1036−-- |
999+-- |
10371000-- | Or for subscription-style:
10381001-- | ```purescript
10391002-- | fiber <- Om.launchOm ctx handlers (Strom.traverseStrom_ callback myStream)
1040−-- | -- ... later ...
1003+-- | -- ... later ...
10411004-- | killFiber (error "Unsubscribed") fiber
10421005-- | ```
10431006-- |
@@ -1060,8 +1023,8 @@ appendStrom toAppend baseStream = mkStrom do
10601023 step <- runStrom baseStream
10611024 case step of
10621025 Done Nothing -> runStrom toAppend
1063− Done (Just chunk) -> pure $ Loop $ Tuple (Just chunk) toAppend -- Emit chunk and continue with toAppend
1064− Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (appendStrom toAppend next)
1026+ Done (Just chunk) -> pure $ Loop $ ((Just chunk) /\ toAppend) -- Emit chunk and continue with toAppend
1027+ Loop ((maybeChunk /\ next)) -> pure $ Loop $ (maybeChunk /\ (appendStrom toAppend next))
10651028
10661029-- | Concatenate an array of streams
10671030-- | Uses foldr with flip to match appendStrom's pipeline-friendly parameter order
@@ -1069,8 +1032,8 @@ concatStrom :: forall ctx err a. Array (Strom ctx err a) -> Strom ctx err a
10691032concatStrom streams = Array.foldr (flip appendStrom) empty streams
10701033
10711034-- | Zip two streams together
1072−zipStrom :: forall ctx err a b. Strom ctx err a -> Strom ctx err b -> Strom ctx err (Tuple a b)
1073−zipStrom = zipWithStrom Tuple
1035+zipStrom :: forall ctx err a b. Strom ctx err a -> Strom ctx err b -> Strom ctx err ((a /\ b))
1036+zipStrom = zipWithStrom (/\)
10741037
10751038-- | Zip two streams with a function
10761039zipWithStrom :: forall ctx err a b c. (a -> b -> c) -> Strom ctx err a -> Strom ctx err b -> Strom ctx err c
@@ -1083,13 +1046,13 @@ zipWithStrom f s1 s2 = mkStrom do
10831046 pure $ Done $ Just zipped
10841047 Done Nothing, _ -> pure $ Done Nothing
10851048 _, Done Nothing -> pure $ Done Nothing
1086− Loop (Tuple maybeChunk1 next1), Loop (Tuple maybeChunk2 next2) ->
1049+ Loop ((maybeChunk1 /\ next1)), Loop ((maybeChunk2 /\ next2)) ->
10871050 -- Both looping: zip chunks if both present, continue with both nexts
10881051 case maybeChunk1, maybeChunk2 of
1089− Just chunk1, Just chunk2 -> pure $ Loop $ Tuple (Just $ Array.zipWith f chunk1 chunk2) (zipWithStrom f next1 next2)
1090− _, _ -> pure $ Loop $ Tuple Nothing (zipWithStrom f next1 next2)
1091− Loop (Tuple _ next1), _ -> pure $ Loop $ Tuple Nothing (zipWithStrom f next1 s2)
1092− _, Loop (Tuple _ next2) -> pure $ Loop $ Tuple Nothing (zipWithStrom f s1 next2)
1052+ Just chunk1, Just chunk2 -> pure $ Loop $ ((Just $ Array.zipWith f chunk1 chunk2) /\ (zipWithStrom f next1 next2))
1053+ _, _ -> pure $ Loop $ (Nothing /\ (zipWithStrom f next1 next2))
1054+ Loop ((_ /\ next1)), _ -> pure $ Loop $ (Nothing /\ (zipWithStrom f next1 s2))
1055+ _, Loop ((_ /\ next2)) -> pure $ Loop $ (Nothing /\ (zipWithStrom f s1 next2))
10931056
10941057-- | Interleave two streams - alternates between elements from each stream
10951058-- | Similar to merge but deterministic - always alternates
@@ -1101,16 +1064,16 @@ interleave s1 s2 = interleaveHelper true s1 s2
11011064 step1 <- runStrom first
11021065 case step1 of
11031066 Done Nothing -> runStrom second
1104− Done (Just chunk1) -> pure $ Loop $ Tuple (Just chunk1) (interleaveHelper false second first)
1105− Loop (Tuple maybeChunk1 next1) ->
1106− pure $ Loop $ Tuple maybeChunk1 (interleaveHelper false second next1)
1067+ Done (Just chunk1) -> pure $ Loop $ ((Just chunk1) /\ (interleaveHelper false second first))
1068+ Loop ((maybeChunk1 /\ next1)) ->
1069+ pure $ Loop $ (maybeChunk1 /\ (interleaveHelper false second next1))
11071070 else do
11081071 step2 <- runStrom second
11091072 case step2 of
11101073 Done Nothing -> runStrom first
1111− Done (Just chunk2) -> pure $ Loop $ Tuple (Just chunk2) (interleaveHelper true first second)
1112− Loop (Tuple maybeChunk2 next2) ->
1113− pure $ Loop $ Tuple maybeChunk2 (interleaveHelper true first next2)
1074+ Done (Just chunk2) -> pure $ Loop $ ((Just chunk2) /\ (interleaveHelper true first second))
1075+ Loop ((maybeChunk2 /\ next2)) ->
1076+ pure $ Loop $ (maybeChunk2 /\ (interleaveHelper true first next2))
11141077
11151078-- | Intersperse a separator element between each element of the stream
11161079intersperse :: forall ctx err a. a -> Strom ctx err a -> Strom ctx err a
@@ -1126,15 +1089,15 @@ intersperse separator stream = intersperseHelper true stream
11261089 if isFirst then Array.intercalate [ separator ] (Array.singleton <$> chunk)
11271090 else Array.cons separator (Array.intercalate [ separator ] (Array.singleton <$> chunk))
11281091 pure $ Done $ Just withSeparators
1129− Loop (Tuple maybeChunk next) ->
1092+ Loop ((maybeChunk /\ next)) ->
11301093 case maybeChunk of
1131− Nothing -> pure $ Loop $ Tuple Nothing (intersperseHelper isFirst next)
1094+ Nothing -> pure $ Loop $ (Nothing /\ (intersperseHelper isFirst next))
11321095 Just chunk -> do
11331096 let
11341097 withSeparators =
11351098 if isFirst then Array.intercalate [ separator ] (Array.singleton <$> chunk)
11361099 else Array.cons separator (Array.intercalate [ separator ] (Array.singleton <$> chunk))
1137− pure $ Loop $ Tuple (Just withSeparators) (intersperseHelper false next)
1100+ pure $ Loop $ ((Just withSeparators) /\ (intersperseHelper false next))
11381101
11391102--------------------------------------------------------------------------------
11401103-- Buffering
@@ -1175,7 +1138,7 @@ bufferChunks capacity stream
11751138 Nothing -> do
11761139 AVar.put unit mutex
11771140 dequeue
1178− Just (Tuple head tail) -> do
1141+ Just ((head /\ tail)) -> do
11791142 liftEffect $ Ref.write tail bufferRef
11801143 liftEffect $ Ref.write (size - 1) sizeRef
11811144 let hasMore = (size - 1) > 0
@@ -1193,7 +1156,7 @@ bufferChunks capacity stream
11931156 Done (Just chunk) -> do
11941157 enqueue (BufferChunk chunk)
11951158 enqueue BufferDone
1196− Loop (Tuple maybeChunk next) -> do
1159+ Loop ((maybeChunk /\ next)) -> do
11971160 case maybeChunk of
11981161 Just chunk -> enqueue (BufferChunk chunk)
11991162 Nothing -> pure unit
@@ -1209,7 +1172,7 @@ bufferChunks capacity stream
12091172 killFiber (Exception.error "done") fiber
12101173 pure $ Done Nothing
12111174 BufferChunk chunk ->
1212− pure $ Loop $ Tuple (Just chunk) consumer
1175+ pure $ Loop $ ((Just chunk) /\ consumer)
12131176
12141177 stepResult <- Om.runReader ctx (runStrom consumer)
12151178 either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
@@ -1249,7 +1212,7 @@ buffer capacity stream
12491212 Nothing -> do
12501213 AVar.put unit mutex
12511214 dequeue
1252− Just (Tuple head tail) -> do
1215+ Just ((head /\ tail)) -> do
12531216 liftEffect $ Ref.write tail bufferRef
12541217 liftEffect $ Ref.write (size - 1) sizeRef
12551218 let hasMore = (size - 1) > 0
@@ -1267,7 +1230,7 @@ buffer capacity stream
12671230 Done (Just chunk) -> do
12681231 traverse_ (\a -> enqueue (BufferChunk [ a ])) chunk
12691232 enqueue BufferDone
1270− Loop (Tuple maybeChunk next) -> do
1233+ Loop ((maybeChunk /\ next)) -> do
12711234 case maybeChunk of
12721235 Just chunk -> traverse_ (\a -> enqueue (BufferChunk [ a ])) chunk
12731236 Nothing -> pure unit
@@ -1283,7 +1246,7 @@ buffer capacity stream
12831246 killFiber (Exception.error "done") fiber
12841247 pure $ Done Nothing
12851248 BufferChunk chunk ->
1286− pure $ Loop $ Tuple (Just chunk) consumer
1249+ pure $ Loop $ ((Just chunk) /\ consumer)
12871250
12881251 stepResult <- Om.runReader ctx (runStrom consumer)
12891252 either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
@@ -1392,7 +1355,7 @@ mergeNDWithStrategy bufferCapacity strategy stream1 stream2 = mkStrom do
13921355 Nothing -> do
13931356 AVar.put unit mutex
13941357 dequeue
1395− Just (Tuple head tail) -> do
1358+ Just ((head /\ tail)) -> do
13961359 liftEffect $ Ref.write tail bufferRef
13971360 liftEffect $ Ref.write (size - 1) sizeRef
13981361 let hasMore = (size - 1) > 0
@@ -1407,14 +1370,14 @@ mergeNDWithStrategy bufferCapacity strategy stream1 stream2 = mkStrom do
14071370 let
14081371 producer s = do
14091372 stepResult <- Om.runReader ctx (runStrom s)
1410− step <- either (\e -> throwError (Exception.error "Stream error")) pure stepResult
1373+ step <- either (\_ -> throwError (Exception.error "Stream error")) pure stepResult
14111374 case step of
14121375 Done Nothing ->
14131376 enqueue (StreamDone StreamId1)
14141377 Done (Just chunk) -> do
14151378 enqueue (DataChunk chunk StreamId1)
14161379 enqueue (StreamDone StreamId1)
1417− Loop (Tuple maybeChunk next) -> do
1380+ Loop ((maybeChunk /\ next)) -> do
14181381 case maybeChunk of
14191382 Just chunk -> enqueue (DataChunk chunk StreamId1)
14201383 Nothing -> pure unit
@@ -1426,14 +1389,14 @@ mergeNDWithStrategy bufferCapacity strategy stream1 stream2 = mkStrom do
14261389 let
14271390 producer s = do
14281391 stepResult <- Om.runReader ctx (runStrom s)
1429− step <- either (\e -> throwError (Exception.error "Stream error")) pure stepResult
1392+ step <- either (\_ -> throwError (Exception.error "Stream error")) pure stepResult
14301393 case step of
14311394 Done Nothing ->
14321395 enqueue (StreamDone StreamId2)
14331396 Done (Just chunk) -> do
14341397 enqueue (DataChunk chunk StreamId2)
14351398 enqueue (StreamDone StreamId2)
1436− Loop (Tuple maybeChunk next) -> do
1399+ Loop ((maybeChunk /\ next)) -> do
14371400 case maybeChunk of
14381401 Just chunk -> enqueue (DataChunk chunk StreamId2)
14391402 Nothing -> pure unit
@@ -1460,7 +1423,7 @@ mergeNDWithStrategy bufferCapacity strategy stream1 stream2 = mkStrom do
14601423 either (\_ -> throwError (Exception.error "Consumer error")) pure stepResult
14611424 DataChunk chunk _streamId ->
14621425 -- Got a chunk
1463− pure $ Loop $ Tuple (Just chunk) (consumer doneLeft doneRight)
1426+ pure $ Loop $ ((Just chunk) /\ (consumer doneLeft doneRight))
14641427
14651428 stepResult <- Om.runReader ctx (runStrom $ consumer false false)
14661429 either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
@@ -1505,7 +1468,7 @@ mergeAllNDWith bufferCapacity streams
15051468 Nothing -> do
15061469 AVar.put unit mutex
15071470 dequeue
1508− Just (Tuple head tail) -> do
1471+ Just ((head /\ tail)) -> do
15091472 liftEffect $ Ref.write tail bufferRef
15101473 liftEffect $ Ref.write (size - 1) sizeRef
15111474 let hasMore = (size - 1) > 0
@@ -1523,7 +1486,7 @@ mergeAllNDWith bufferCapacity streams
15231486 Done (Just chunk) -> do
15241487 enqueue (MergeChunk chunk)
15251488 enqueue MergeDone
1526− Loop (Tuple maybeChunk next) -> do
1489+ Loop ((maybeChunk /\ next)) -> do
15271490 case maybeChunk of
15281491 Just chunk -> enqueue (MergeChunk chunk)
15291492 Nothing -> pure unit
@@ -1544,7 +1507,7 @@ mergeAllNDWith bufferCapacity streams
15441507 stepResult <- Om.runReader ctx (runStrom $ consumer (doneCount + 1))
15451508 either (\_ -> throwError (Exception.error "Consumer error")) pure stepResult
15461509 MergeChunk chunk ->
1547− pure $ Loop $ Tuple (Just chunk) (consumer doneCount)
1510+ pure $ Loop $ ((Just chunk) /\ (consumer doneCount))
15481511
15491512 stepResult <- Om.runReader ctx (runStrom $ consumer 0)
15501513 either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
@@ -1568,23 +1531,23 @@ mapPar concurrency f stream
15681531 let groups = chunksOf concurrency chunk
15691532 results <- traverse (\group -> Om.inParallel (Functor.map f group)) groups
15701533 pure $ Done $ Just $ Array.concat results
1571− Loop (Tuple maybeChunk next) -> do
1534+ Loop ((maybeChunk /\ next)) -> do
15721535 mappedChunk <- case maybeChunk of
15731536 Nothing -> pure Nothing
15741537 Just chunk -> do
15751538 let groups = chunksOf concurrency chunk
15761539 results <- traverse (\group -> Om.inParallel (Functor.map f group)) groups
15771540 pure $ Just $ Array.concat results
1578− pure $ Loop $ Tuple mappedChunk (mapPar concurrency f next)
1541+ pure $ Loop $ (mappedChunk /\ (mapPar concurrency f next))
15791542 where
15801543 chunksOf :: forall x. Int -> Array x -> Array (Array x)
15811544 chunksOf n arr
15821545 | n <= 0 || Array.null arr = []
15831546 | otherwise =
1584− let
1585− group = Array.take n arr
1586− rest = Array.drop n arr
1587− in
1547+ do
1548+ let
1549+ group = Array.take n arr
1550+ rest = Array.drop n arr
15881551 if Array.null group then [] else Array.cons group (chunksOf n rest)
15891552
15901553-- | Unordered parallel map (currently same as mapPar)
@@ -1720,7 +1683,7 @@ instance Functor (Strom ctx err) where
17201683 case step of
17211684 Done Nothing -> pure $ Done Nothing
17221685 Done (Just chunk) -> pure $ Done $ Just $ Functor.map f chunk
1723− Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple (Functor.map (Functor.map f) maybeChunk) (Functor.map f next)
1686+ Loop ((maybeChunk /\ next)) -> pure $ Loop $ ((Functor.map (Functor.map f) maybeChunk) /\ (Functor.map f next))
17241687 )
17251688
17261689instance Apply (Strom ctx err) where
@@ -1729,29 +1692,29 @@ instance Apply (Strom ctx err) where
17291692 runStrom as >>= \stepA ->
17301693 case stepF, stepA of
17311694 Done (Just funcs), Done (Just vals) ->
1732− let
1733− results = do
1734− f <- funcs
1735− a <- vals
1736− pure (f a)
1737− in
1695+ do
1696+ let
1697+ results = do
1698+ f <- funcs
1699+ a <- vals
1700+ pure (f a)
17381701 pure $ Done $ Just results
17391702 Done Nothing, _ -> pure $ Done Nothing
17401703 _, Done Nothing -> pure $ Done Nothing
1741− Loop (Tuple maybeChunkF nextF), Loop (Tuple maybeChunkA nextA) ->
1704+ Loop ((maybeChunkF /\ nextF)), Loop ((maybeChunkA /\ nextA)) ->
17421705 -- Apply chunks if both present
17431706 case maybeChunkF, maybeChunkA of
17441707 Just chunkF, Just chunkA ->
1745− let
1746− applied = do
1747− f <- chunkF
1748− a <- chunkA
1749− pure (f a)
1750− in
1751− pure $ Loop $ Tuple (Just applied) (nextF <*> nextA)
1752− _, _ -> pure $ Loop $ Tuple Nothing (nextF <*> nextA)
1753− Loop (Tuple _ nextF), _ -> pure $ Loop $ Tuple Nothing (nextF <*> as)
1754− _, Loop (Tuple _ nextA) -> pure $ Loop $ Tuple Nothing (fs <*> nextA)
1708+ do
1709+ let
1710+ applied = do
1711+ f <- chunkF
1712+ a <- chunkA
1713+ pure (f a)
1714+ pure $ Loop $ ((Just applied) /\ (nextF <*> nextA))
1715+ _, _ -> pure $ Loop $ (Nothing /\ (nextF <*> nextA))
1716+ Loop ((_ /\ nextF)), _ -> pure $ Loop $ (Nothing /\ (nextF <*> as))
1717+ _, Loop ((_ /\ nextA)) -> pure $ Loop $ (Nothing /\ (fs <*> nextA))
17551718 )
17561719
17571720instance Applicative (Strom ctx err) where
@@ -1763,19 +1726,18 @@ instance Bind (Strom ctx err) where
17631726 case step of
17641727 Done Nothing -> pure $ Done Nothing
17651728 Done (Just chunk) ->
1766− let
1767− streams = Functor.map f chunk
1768− in
1729+ do
1730+ let streams = Functor.map f chunk
17691731 runStrom $ concatStrom streams
1770− Loop (Tuple maybeChunk next) ->
1732+ Loop ((maybeChunk /\ next)) ->
17711733 -- When looping, if we have a chunk, we need to process it through f and concat
17721734 case maybeChunk of
1773− Nothing -> pure $ Loop $ Tuple Nothing (next >>= f)
1735+ Nothing -> pure $ Loop $ (Nothing /\ (next >>= f))
17741736 Just chunk ->
1775− let
1776− streams = Functor.map f chunk
1777− prefixStream = concatStrom streams
1778− in
1737+ do
1738+ let
1739+ streams = Functor.map f chunk
1740+ prefixStream = concatStrom streams
17791741 runStrom $ appendStrom prefixStream (next >>= f)
17801742 )
17811743
@@ -1786,8 +1748,8 @@ instance Semigroup (Strom ctx err a) where
17861748 ( runStrom s1 >>= \step ->
17871749 case step of
17881750 Done Nothing -> runStrom s2
1789− Done (Just chunk) -> pure $ Loop $ Tuple (Just chunk) s2
1790− Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (next <> s2)
1751+ Done (Just chunk) -> pure $ Loop $ ((Just chunk) /\ s2)
1752+ Loop ((maybeChunk /\ next)) -> pure $ Loop $ (maybeChunk /\ (next <> s2))
17911753 )
17921754
17931755instance Monoid (Strom ctx err a) where
@@ -1798,8 +1760,8 @@ instance Alt (Strom ctx err) where
17981760 ( runStrom s1 >>= \step ->
17991761 case step of
18001762 Done Nothing -> runStrom s2
1801− Done (Just chunk) -> pure $ Loop $ Tuple (Just chunk) s2
1802− Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (next <|> s2)
1763+ Done (Just chunk) -> pure $ Loop $ ((Just chunk) /\ s2)
1764+ Loop ((maybeChunk /\ next)) -> pure $ Loop $ (maybeChunk /\ (next <|> s2))
18031765 )
18041766
18051767instance Plus (Strom ctx err) where
src/Yoga/Om/Strom.purs.backupnew file+1809
@@ -0,0 +1,1809 @@
1+-- @inline export mkStrom arity=1
2+-- @inline export runStrom arity=1
3+-- @inline export mapStrom arity=2
4+-- @inline export filterStrom arity=2
5+-- @inline export takeStrom arity=2
6+-- @inline export runFold arity=3
7+-- @inline export runDrain arity=1
8+-- @inline export rangeStrom arity=2
9+-- @inline export scanStrom arity=3
10+-- @inline export zipWithStrom arity=3
11+-- @inline export mapPar arity=3
12+-- @inline export bindStrom arity=2
13+module Yoga.Om.Strom
14+ ( Strom
15+ -- Construction
16+ , empty
17+ , succeed
18+ , fail
19+ -- , die -- TODO: Fix type constraints
20+ , fromAff
21+ , fromOm
22+ , fromArray
23+ , fromFoldable
24+ , rangeStrom
25+ , iterateStrom
26+ , iterateStromInfinite
27+ , repeatStrom
28+ , repeatStromInfinite
29+ , repeatOmStrom
30+ , repeatOmStromInfinite
31+ , unfoldStrom
32+ , unfoldOmStrom
33+ -- Running
34+ , runCollect
35+ , runDrain
36+ , runFold
37+ , traverseStrom_
38+ , forStrom_
39+ , forMStrom_
40+ , traverseMStrom_
41+ -- Transformations
42+ , mapStrom
43+ , mapMStrom
44+ , bindStrom
45+ , scanStrom
46+ , mapAccumStrom
47+ , tapStrom
48+ , tapMStrom
49+ , filterStrom
50+ -- Selection
51+ , takeStrom
52+ , takeWhileStrom
53+ , takeUntilStrom
54+ , dropStrom
55+ , dropWhileStrom
56+ , filterMStrom
57+ , collectStrom
58+ , collectMStrom
59+ , changesStrom
60+ -- Grouping
61+ , groupedStrom
62+ , chunkedStrom
63+ , groupByStrom
64+ , partition
65+ , partitionMap
66+ -- Timing
67+ , debounce
68+ , throttle
69+ , delayStrom
70+ -- Combining
71+ , appendStrom
72+ , concatStrom
73+ , zipStrom
74+ , zipWithStrom
75+ , interleave
76+ , intersperse
77+ , HaltStrategy(..)
78+ , merge
79+ , mergeAll
80+ , mergeWith
81+ , mergeHaltEither
82+ , mergeHaltLeft
83+ , mergeHaltRight
84+ , mergeND
85+ , mergeNDWith
86+ , mergeAllND
87+ , mergeAllNDWith
88+ , race
89+ , raceAll
90+ -- Parallel Processing
91+ , mapPar
92+ , mapMPar
93+ , foreachPar
94+ , mapParUnordered
95+ , mapMParUnordered
96+ , foreachParUnordered
97+ -- Buffering
98+ , buffer
99+ , bufferChunks
100+ -- Error Handling
101+ , catchAll
102+ -- , catchSome -- TODO: Fix type constraints
103+ , retry
104+ , retryN
105+ , timeout
106+ , ensuring
107+ -- Resource Safety
108+ , bracket
109+ , bracketExit
110+ , acquireRelease
111+ ) where
112+
113+import Prelude
114+
115+import Control.Alt (class Alt, (<|>))
116+import Control.Alternative (class Alternative)
117+import Control.Monad.Error.Class (throwError)
118+import Control.Monad.Rec.Class (Step(..), tailRecM)
119+import Control.Monad.ST as ST
120+import Control.Monad.ST.Ref as STRef
121+import Control.Plus (class Plus)
122+import Data.Array as Array
123+import Data.Array.ST as STArray
124+import Data.Array.ST.Iterator as Iterator
125+import Data.Either (Either(..), either)
126+import Data.Foldable (class Foldable, foldl)
127+import Data.Functor (map) as Functor
128+import Data.List as List
129+import Data.Maybe (Maybe(..))
130+import Data.Time.Duration (Milliseconds(..))
131+import Data.Traversable (traverse, traverse_)
132+import Data.Tuple (Tuple(..))
133+import Data.Tuple.Nested (type (/\), (/\))
134+import Data.Variant (Variant)
135+import Effect.Aff (Aff, Error, delay, forkAff, joinFiber, killFiber)
136+import Effect.Aff.AVar as AVar
137+import Effect.Aff.Class (liftAff)
138+import Effect.Exception as Exception
139+import Effect.Now (now)
140+import Effect.Ref as Ref
141+import Effect.Class (liftEffect)
142+import Data.CatQueue as CatQueue
143+import Data.CatQueue (CatQueue)
144+import Data.DateTime.Instant (unInstant)
145+import Yoga.Om (Om)
146+import Yoga.Om as Om
147+
148+--------------------------------------------------------------------------------
149+-- Core Types
150+--------------------------------------------------------------------------------
151+
152+-- | A Strom is a stream of values that can:
153+-- | - Emit zero or more values of type `a`
154+-- | - Fail with errors tracked in the `err` row
155+-- | - Require a context `ctx`
156+-- | - Perform effects in Om
157+-- |
158+-- | Think of it as: `Om ctx err (Array a)` but lazy and chunked
159+-- |
160+-- | The Step type is interpreted as:
161+-- | - Loop (chunk, next) => emit chunk (if present) and continue with next
162+-- | - Done finalChunk => emit final chunk (if present) and stop
163+newtype Strom ctx err a = Strom
164+ { pull :: Om ctx err (Step (Tuple (Maybe (Chunk a)) (Strom ctx err a)) (Maybe (Chunk a)))
165+ }
166+
167+-- | Chunks for efficient batch processing
168+type Chunk a = Array a
169+
170+-- | Helper to create a Strom from a pull function
171+mkStrom :: forall ctx err a. Om ctx err (Step (Tuple (Maybe (Chunk a)) (Strom ctx err a)) (Maybe (Chunk a))) -> Strom ctx err a
172+mkStrom pull = Strom { pull }
173+
174+-- | Helper to unwrap a Strom
175+runStrom :: forall ctx err a. Strom ctx err a -> Om ctx err (Step (Tuple (Maybe (Chunk a)) (Strom ctx err a)) (Maybe (Chunk a)))
176+runStrom (Strom s) = s.pull
177+
178+--------------------------------------------------------------------------------
179+-- Non-Deterministic Merge Types
180+--------------------------------------------------------------------------------
181+
182+-- | Stream identifier for tracking which stream produced data
183+data StreamId = StreamId1 | StreamId2
184+
185+derive instance eqStreamId :: Eq StreamId
186+derive instance ordStreamId :: Ord StreamId
187+
188+-- | Messages passed through the coordination queue
189+data QueueItem a
190+ = DataChunk (Array a) StreamId
191+ | StreamDone StreamId
192+
193+derive instance functorQueueItem :: Functor QueueItem
194+
195+-- | Messages for mergeAllND buffered queue
196+data MergeAllItem a
197+ = MergeChunk (Array a)
198+ | MergeDone
199+
200+derive instance functorMergeAllItem :: Functor MergeAllItem
201+
202+-- | Merge termination strategy (like ZStream HaltStrategy)
203+data HaltStrategy
204+ = HaltBoth
205+ | HaltEither
206+ | HaltLeft
207+ | HaltRight
208+
209+derive instance eqHaltStrategy :: Eq HaltStrategy
210+
211+-- | Messages for buffering queues
212+data BufferItem a
213+ = BufferChunk (Array a)
214+ | BufferDone
215+
216+derive instance functorBufferItem :: Functor BufferItem
217+
218+--------------------------------------------------------------------------------
219+-- Construction
220+--------------------------------------------------------------------------------
221+
222+-- | An empty stream
223+empty :: forall ctx err a. Strom ctx err a
224+empty = mkStrom $ pure $ Done Nothing
225+
226+-- | A stream with a single element
227+succeed :: forall ctx err a. a -> Strom ctx err a
228+succeed a = mkStrom $ pure $ Done $ Just [ a ]
229+
230+-- | A stream that fails immediately with an empty result (no error thrown)
231+fail :: forall ctx err a. Strom ctx err a
232+fail = empty
233+
234+-- TODO: Add die function with proper type constraints
235+-- die :: forall ctx err errors a. SingletonVariantRecord err (exception :: Error | errors) => Record err -> Strom ctx (exception :: Error | errors) a
236+-- die error = mkStrom $ Om.throw error
237+
238+-- | Create a stream from an Aff computation
239+fromAff :: forall ctx err a. Aff a -> Strom ctx err a
240+fromAff aff = mkStrom do
241+ value <- liftAff aff
242+ pure $ Done $ Just [ value ]
243+
244+-- | Create a stream from an Om computation
245+fromOm :: forall ctx err a. Om ctx err a -> Strom ctx err a
246+fromOm om = mkStrom do
247+ value <- om
248+ pure $ Done $ Just [ value ]
249+
250+-- | Create a stream from an array
251+fromArray :: forall ctx err a. Array a -> Strom ctx err a
252+fromArray arr =
253+ if Array.null arr then mkStrom $ pure $ Done Nothing
254+ else mkStrom $ pure $ Done $ Just arr
255+
256+-- | Create a stream from any foldable
257+fromFoldable :: forall ctx err f a. Foldable f => f a -> Strom ctx err a
258+fromFoldable = fromArray <<< Array.fromFoldable
259+
260+-- | Create a stream of integers in a range [start, end)
261+rangeStrom :: forall ctx err. Int -> Int -> Strom ctx err Int
262+rangeStrom start end = rangeHelper start end
263+ where
264+ rangeHelper current limit
265+ | current >= limit = empty
266+ | otherwise = mkStrom do
267+ let
268+ chunkSize = 1000
269+ chunkEnd = min (current + chunkSize) limit
270+ chunk = Array.range current (chunkEnd - 1)
271+ if chunkEnd >= limit then pure $ Done $ Just chunk -- Final chunk
272+ else pure $ Loop $ Tuple (Just chunk) (rangeHelper chunkEnd limit) -- Emit chunk and continue
273+
274+-- | Iterate producing values - limited to ~10,000 elements for stack safety
275+-- | For truly infinite streams, use `iterateStromInfinite` (with Aff overhead)
276+iterateStrom :: forall ctx err a. (a -> a) -> a -> Strom ctx err a
277+iterateStrom f initial = fromArray $ ST.run do
278+ arr <- STArray.unsafeThaw []
279+ valRef <- STRef.new initial
280+ iter <- Iterator.iterator (\i -> if i < 10000 then Just i else Nothing)
281+ Iterator.iterate iter \_ -> do
282+ v <- STRef.read valRef
283+ void $ STArray.push v arr
284+ void $ STRef.modify f valRef
285+ STArray.unsafeFreeze arr
286+
287+-- | Truly infinite iteration - stack-safe via Aff async boundaries
288+-- | Adds tiny delay (0ms) at each chunk to reset the stack
289+-- | Use this when you need more than 10k elements
290+iterateStromInfinite :: forall ctx err a. (a -> a) -> a -> Strom ctx err a
291+iterateStromInfinite f initial = iterateHelper initial
292+ where
293+ iterateHelper current = mkStrom do
294+ -- Async boundary resets the stack!
295+ liftAff $ delay (Milliseconds 0.0)
296+ let
297+ chunkSize = 10000
298+ -- Build chunk using STArray with Iterator (stack-safe!)
299+ result = ST.run do
300+ arr <- STArray.unsafeThaw []
301+ valRef <- STRef.new current
302+ iter <- Iterator.iterator (\i -> if i < chunkSize then Just i else Nothing)
303+ Iterator.iterate iter \_ -> do
304+ val <- STRef.read valRef
305+ void $ STArray.push val arr
306+ void $ STRef.modify f valRef
307+ finalVal <- STRef.read valRef
308+ frozen <- STArray.unsafeFreeze arr
309+ pure { arr: frozen, nextVal: finalVal }
310+ pure $ Loop $ Tuple (Just result.arr) (iterateHelper result.nextVal)
311+
312+-- | Repeat a value - limited to ~10,000 elements for stack safety
313+repeatStrom :: forall ctx err a. a -> Strom ctx err a
314+repeatStrom a = fromArray (Array.replicate 10000 a)
315+
316+-- | Truly infinite repeat - stack-safe via Aff async boundaries
317+-- | Adds tiny delay (0ms) at each chunk to reset the stack
318+repeatStromInfinite :: forall ctx err a. a -> Strom ctx err a
319+repeatStromInfinite a = repeatHelper unit
320+ where
321+ repeatHelper _ = mkStrom do
322+ -- Async boundary resets the stack!
323+ liftAff $ delay (Milliseconds 0.0)
324+ let chunk = Array.replicate 10000 a
325+ pure $ Loop $ Tuple (Just chunk) (repeatHelper unit)
326+
327+-- | Repeat an Om computation - limited to 100 iterations for stack safety
328+-- | For truly infinite repetition, use `repeatOmStromInfinite` (with Aff overhead)
329+repeatOmStrom :: forall ctx err a. Om ctx err a -> Strom ctx err a
330+repeatOmStrom om = repeatOmHelper 0
331+ where
332+ maxCount = 100
333+ repeatOmHelper count
334+ | count >= maxCount = empty
335+ | otherwise = mkStrom do
336+ value <- om
337+ pure $ Loop $ Tuple (Just [ value ]) (repeatOmHelper (count + 1))
338+
339+-- | Truly infinite Om repetition - stack-safe via Aff async boundaries
340+-- | Adds tiny delay (0ms) at each pull to reset the stack
341+repeatOmStromInfinite :: forall ctx err a. Om ctx err a -> Strom ctx err a
342+repeatOmStromInfinite om = repeatOmHelper unit
343+ where
344+ repeatOmHelper _ = mkStrom do
345+ -- Async boundary resets the stack!
346+ liftAff $ delay (Milliseconds 0.0)
347+ value <- om
348+ pure $ Loop $ Tuple (Just [ value ]) (repeatOmHelper unit)
349+
350+-- | Unfold a stream from a seed value - can be infinite if f never returns Nothing
351+-- | Emits chunks lazily, terminates when f returns Nothing
352+unfoldStrom :: forall ctx err a b. (b -> Maybe (Tuple a b)) -> b -> Strom ctx err a
353+unfoldStrom f seed = unfoldHelper seed
354+ where
355+ unfoldHelper currentSeed = mkStrom do
356+ let chunk = buildChunk currentSeed 10000
357+ case chunk.finalSeed of
358+ Nothing -> pure $ Done $ if Array.null chunk.values then Nothing else Just chunk.values
359+ Just nextSeed ->
360+ if Array.null chunk.values then runStrom (unfoldHelper nextSeed)
361+ else pure $ Loop $ Tuple (Just chunk.values) (unfoldHelper nextSeed)
362+
363+ buildChunk seed' size = ST.run do
364+ arr <- STArray.unsafeThaw []
365+ seedRef <- STRef.new seed'
366+ stoppedRef <- STRef.new false
367+ iter <- Iterator.iterator (\i -> if i < size then Just i else Nothing)
368+ Iterator.iterate iter \_ -> do
369+ stopped <- STRef.read stoppedRef
370+ when (not stopped) do
371+ s <- STRef.read seedRef
372+ case f s of
373+ Nothing -> void $ STRef.write true stoppedRef
374+ Just (Tuple value nextS) -> do
375+ void $ STArray.push value arr
376+ void $ STRef.write nextS seedRef
377+ frozen <- STArray.unsafeFreeze arr
378+ stopped <- STRef.read stoppedRef
379+ finalSeed <- STRef.read seedRef
380+ pure { values: frozen, finalSeed: if stopped then Nothing else Just finalSeed }
381+
382+-- | Unfold a stream with an effectful function
383+unfoldOmStrom :: forall ctx err a b. (b -> Om ctx err (Maybe (Tuple a b))) -> b -> Strom ctx err a
384+unfoldOmStrom f seed = mkStrom go
385+ where
386+ go = do
387+ result <- f seed
388+ case result of
389+ Nothing -> pure $ Done Nothing
390+ Just (Tuple value nextSeed) -> pure $ Loop $ Tuple (Just [ value ]) (unfoldOmStrom f nextSeed)
391+
392+--------------------------------------------------------------------------------
393+-- Transformations
394+--------------------------------------------------------------------------------
395+
396+-- | Map over elements
397+mapStrom :: forall ctx err a b. (a -> b) -> Strom ctx err a -> Strom ctx err b
398+mapStrom f stream = mkStrom do
399+ step <- runStrom stream
400+ case step of
401+ Done Nothing -> pure $ Done Nothing
402+ Done (Just chunk) -> pure $ Done $ Just $ Functor.map f chunk
403+ Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple (Functor.map (Functor.map f) maybeChunk) (mapStrom f next)
404+
405+-- | Map with a monadic effect
406+mapMStrom :: forall ctx err a b. (a -> Om ctx err b) -> Strom ctx err a -> Strom ctx err b
407+mapMStrom f stream = mkStrom do
408+ step <- runStrom stream
409+ case step of
410+ Done Nothing -> pure $ Done Nothing
411+ Done (Just chunk) -> do
412+ mapped <- traverse f chunk
413+ pure $ Done $ Just mapped
414+ Loop (Tuple maybeChunk next) -> do
415+ mappedChunk <- case maybeChunk of
416+ Nothing -> pure Nothing
417+ Just chunk -> Just <$> traverse f chunk
418+ pure $ Loop $ Tuple mappedChunk (mapMStrom f next)
419+
420+-- | Tap (observe without modifying)
421+tapStrom :: forall ctx err a. (a -> Unit) -> Strom ctx err a -> Strom ctx err a
422+tapStrom f stream = mkStrom do
423+ step <- runStrom stream
424+ case step of
425+ Done Nothing -> pure $ Done Nothing
426+ Done (Just chunk) -> do
427+ let _ = Functor.map f chunk
428+ pure $ Done $ Just chunk
429+ Loop (Tuple maybeChunk next) -> do
430+ let
431+ tappedChunk = case maybeChunk of
432+ Nothing -> Nothing
433+ Just chunk ->
434+ let
435+ _ = Functor.map f chunk
436+ in
437+ Just chunk
438+ pure $ Loop $ Tuple tappedChunk (tapStrom f next)
439+
440+-- | Tap with monadic effect (observe with effects)
441+tapMStrom :: forall ctx err a. (a -> Om ctx err Unit) -> Strom ctx err a -> Strom ctx err a
442+tapMStrom f stream = mkStrom do
443+ step <- runStrom stream
444+ case step of
445+ Done Nothing -> pure $ Done Nothing
446+ Done (Just chunk) -> do
447+ _ <- traverse f chunk
448+ pure $ Done $ Just chunk
449+ Loop (Tuple maybeChunk next) -> do
450+ tappedChunk <- case maybeChunk of
451+ Nothing -> pure Nothing
452+ Just chunk -> do
453+ _ <- traverse f chunk
454+ pure $ Just chunk
455+ pure $ Loop $ Tuple tappedChunk (tapMStrom f next)
456+
457+-- | Bind for streams (monadic flatMap/chain)
458+-- | Use >>= operator for cleaner syntax: `stream >>= f`
459+bindStrom :: forall ctx err a b. (a -> Strom ctx err b) -> Strom ctx err a -> Strom ctx err b
460+bindStrom f stream = stream >>= f
461+
462+-- | Scan with accumulator (like foldl but emits intermediate results)
463+scanStrom :: forall ctx err a b. (b -> a -> b) -> b -> Strom ctx err a -> Strom ctx err b
464+scanStrom f initial stream = scanHelper initial stream
465+ where
466+ scanHelper acc s = mkStrom do
467+ step <- runStrom s
468+ case step of
469+ Done Nothing -> pure $ Done Nothing
470+ Done (Just chunk) -> do
471+ -- Build results by folding and collecting intermediate values
472+ let
473+ Tuple _ results = Array.foldl
474+ ( \(Tuple prevAcc accList) item ->
475+ let
476+ newAcc = f prevAcc item
477+ in
478+ Tuple newAcc (Array.snoc accList newAcc)
479+ )
480+ (Tuple acc [])
481+ chunk
482+ pure $ Done $ if Array.null results then Nothing else Just results
483+ Loop (Tuple maybeChunk next) ->
484+ case maybeChunk of
485+ Nothing -> pure $ Loop $ Tuple Nothing (scanHelper acc next)
486+ Just chunk -> do
487+ let
488+ Tuple newAcc results = Array.foldl
489+ ( \(Tuple prevAcc accList) item ->
490+ let
491+ currAcc = f prevAcc item
492+ in
493+ Tuple currAcc (Array.snoc accList currAcc)
494+ )
495+ (Tuple acc [])
496+ chunk
497+ if Array.null results then pure $ Loop $ Tuple Nothing (scanHelper newAcc next)
498+ else pure $ Loop $ Tuple (Just results) (scanHelper newAcc next)
499+
500+-- | Map with accumulator (stateful map)
501+mapAccumStrom :: forall ctx err a b s. (s -> a -> Tuple s b) -> s -> Strom ctx err a -> Strom ctx err b
502+mapAccumStrom f initial stream = mapAccumHelper initial stream
503+ where
504+ mapAccumHelper state s = mkStrom do
505+ step <- runStrom s
506+ case step of
507+ Done Nothing -> pure $ Done Nothing
508+ Done (Just chunk) -> do
509+ let
510+ Tuple finalState results = Array.foldl
511+ ( \(Tuple st acc) a ->
512+ let
513+ Tuple newSt b = f st a
514+ in
515+ Tuple newSt (Array.snoc acc b)
516+ )
517+ (Tuple state [])
518+ chunk
519+ pure $ Done $ if Array.null results then Nothing else Just results
520+ Loop (Tuple maybeChunk next) ->
521+ case maybeChunk of
522+ Nothing -> pure $ Loop $ Tuple Nothing (mapAccumHelper state next)
523+ Just chunk -> do
524+ let
525+ Tuple newState results = Array.foldl
526+ ( \(Tuple st acc) a ->
527+ let
528+ Tuple newSt b = f st a
529+ in
530+ Tuple newSt (Array.snoc acc b)
531+ )
532+ (Tuple state [])
533+ chunk
534+ if Array.null results then pure $ Loop $ Tuple Nothing (mapAccumHelper newState next)
535+ else pure $ Loop $ Tuple (Just results) (mapAccumHelper newState next)
536+
537+-- | Filter elements
538+filterStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
539+filterStrom predicate stream = mkStrom do
540+ step <- runStrom stream
541+ case step of
542+ Done Nothing -> pure $ Done Nothing
543+ Done (Just chunk) -> do
544+ let filtered = Array.filter predicate chunk
545+ pure $ Done $ if Array.null filtered then Nothing else Just filtered
546+ Loop (Tuple maybeChunk next) -> do
547+ let
548+ filteredChunk = case maybeChunk of
549+ Nothing -> Nothing
550+ Just chunk ->
551+ let
552+ filtered = Array.filter predicate chunk
553+ in
554+ if Array.null filtered then Nothing else Just filtered
555+ pure $ Loop $ Tuple filteredChunk (filterStrom predicate next)
556+
557+--------------------------------------------------------------------------------
558+-- Selection
559+--------------------------------------------------------------------------------
560+
561+-- | Take n elements (with proper state tracking across chunks)
562+takeStrom :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a
563+takeStrom n stream = takeHelper n stream
564+ where
565+ takeHelper remaining s
566+ | remaining <= 0 = empty
567+ | otherwise = mkStrom do
568+ step <- runStrom s
569+ case step of
570+ Done Nothing -> pure $ Done Nothing
571+ Done (Just chunk) -> do
572+ let
573+ taken = Array.take remaining chunk
574+ numTaken = Array.length taken
575+ if numTaken == 0 then pure $ Done Nothing
576+ else pure $ Done $ Just taken
577+ Loop (Tuple maybeChunk next) ->
578+ case maybeChunk of
579+ Nothing -> pure $ Loop $ Tuple Nothing (takeHelper remaining next)
580+ Just chunk -> do
581+ let
582+ chunkLen = Array.length chunk
583+ taken = Array.take remaining chunk
584+ numTaken = Array.length taken
585+ newRemaining = remaining - numTaken
586+ if numTaken == 0 then pure $ Done Nothing
587+ else if newRemaining <= 0 then pure $ Done $ Just taken
588+ else pure $ Loop $ Tuple (Just taken) (takeHelper newRemaining next)
589+
590+-- | Take while predicate is true
591+takeWhileStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
592+takeWhileStrom predicate stream = mkStrom do
593+ step <- runStrom stream
594+ case step of
595+ Done Nothing -> pure $ Done Nothing
596+ Done (Just chunk) -> do
597+ let taken = Array.takeWhile predicate chunk
598+ if Array.length taken < Array.length chunk then pure $ Done $ Just taken
599+ else pure $ Done $ Just taken
600+ Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (takeWhileStrom predicate next)
601+
602+-- | Drop n elements (with proper state tracking across chunks)
603+dropStrom :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a
604+dropStrom n stream
605+ | n <= 0 = stream
606+ | otherwise = dropHelper n stream
607+ where
608+ dropHelper remaining s
609+ | remaining <= 0 = s
610+ | otherwise = mkStrom do
611+ step <- runStrom s
612+ case step of
613+ Done Nothing -> pure $ Done Nothing
614+ Done (Just chunk) -> do
615+ let dropped = Array.drop remaining chunk
616+ pure $ Done $ if Array.null dropped then Nothing else Just dropped
617+ Loop (Tuple maybeChunk next) ->
618+ case maybeChunk of
619+ Nothing -> pure $ Loop $ Tuple Nothing (dropHelper remaining next)
620+ Just chunk ->
621+ let
622+ chunkLen = Array.length chunk
623+ in
624+ if remaining >= chunkLen then pure $ Loop $ Tuple Nothing (dropHelper (remaining - chunkLen) next)
625+ else
626+ let
627+ dropped = Array.drop remaining chunk
628+ in
629+ pure $ Loop $ Tuple (Just dropped) next
630+
631+-- | Take until predicate is true (includes the matching element)
632+takeUntilStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
633+takeUntilStrom predicate stream = mkStrom do
634+ step <- runStrom stream
635+ case step of
636+ Done Nothing -> pure $ Done Nothing
637+ Done (Just chunk) -> do
638+ case Array.findIndex predicate chunk of
639+ Just idx -> pure $ Done $ Just $ Array.take (idx + 1) chunk -- Include the matching element
640+ Nothing -> pure $ Done $ Just chunk
641+ Loop (Tuple maybeChunk next) ->
642+ case maybeChunk of
643+ Nothing -> pure $ Loop $ Tuple Nothing (takeUntilStrom predicate next)
644+ Just chunk -> case Array.findIndex predicate chunk of
645+ Just idx -> pure $ Done $ Just $ Array.take (idx + 1) chunk -- Found match, stop here
646+ Nothing -> pure $ Loop $ Tuple (Just chunk) (takeUntilStrom predicate next) -- Keep going
647+
648+-- | Drop while predicate is true
649+dropWhileStrom :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Strom ctx err a
650+dropWhileStrom predicate stream = dropWhileHelper true stream
651+ where
652+ dropWhileHelper stillDropping s
653+ | not stillDropping = s
654+ | otherwise = mkStrom do
655+ step <- runStrom s
656+ case step of
657+ Done Nothing -> pure $ Done Nothing
658+ Done (Just chunk) -> do
659+ let dropped = Array.dropWhile predicate chunk
660+ pure $ Done $ if Array.null dropped then Nothing else Just dropped
661+ Loop (Tuple maybeChunk next) ->
662+ case maybeChunk of
663+ Nothing -> pure $ Loop $ Tuple Nothing (dropWhileHelper stillDropping next)
664+ Just chunk -> do
665+ let dropped = Array.dropWhile predicate chunk
666+ if Array.null dropped then pure $ Loop $ Tuple Nothing (dropWhileHelper true next)
667+ else pure $ Loop $ Tuple (Just dropped) next -- Found elements to keep, stop dropping
668+
669+-- | Filter with monadic predicate
670+filterMStrom :: forall ctx err a. (a -> Om ctx err Boolean) -> Strom ctx err a -> Strom ctx err a
671+filterMStrom predicate stream = mkStrom do
672+ step <- runStrom stream
673+ case step of
674+ Done Nothing -> pure $ Done Nothing
675+ Done (Just chunk) -> do
676+ filtered <- Array.filterA predicate chunk
677+ pure $ Done $ if Array.null filtered then Nothing else Just filtered
678+ Loop (Tuple maybeChunk next) -> do
679+ filteredChunk <- case maybeChunk of
680+ Nothing -> pure Nothing
681+ Just chunk -> do
682+ filtered <- Array.filterA predicate chunk
683+ pure $ if Array.null filtered then Nothing else Just filtered
684+ pure $ Loop $ Tuple filteredChunk (filterMStrom predicate next)
685+
686+-- | Collect with partial function (mapMaybe)
687+collectStrom :: forall ctx err a b. (a -> Maybe b) -> Strom ctx err a -> Strom ctx err b
688+collectStrom f stream = mkStrom do
689+ step <- runStrom stream
690+ case step of
691+ Done Nothing -> pure $ Done Nothing
692+ Done (Just chunk) -> do
693+ let collected = Array.mapMaybe f chunk
694+ pure $ Done $ if Array.null collected then Nothing else Just collected
695+ Loop (Tuple maybeChunk next) ->
696+ let
697+ collected = case maybeChunk of
698+ Nothing -> Nothing
699+ Just chunk ->
700+ let
701+ c = Array.mapMaybe f chunk
702+ in
703+ if Array.null c then Nothing else Just c
704+ in
705+ pure $ Loop $ Tuple collected (collectStrom f next)
706+
707+-- | Collect with monadic partial function
708+collectMStrom :: forall ctx err a b. (a -> Om ctx err (Maybe b)) -> Strom ctx err a -> Strom ctx err b
709+collectMStrom f stream = mkStrom do
710+ step <- runStrom stream
711+ case step of
712+ Done Nothing -> pure $ Done Nothing
713+ Done (Just chunk) -> do
714+ maybes <- traverse f chunk
715+ let collected = Array.mapMaybe identity maybes
716+ pure $ Done $ if Array.null collected then Nothing else Just collected
717+ Loop (Tuple maybeChunk next) -> do
718+ collected <- case maybeChunk of
719+ Nothing -> pure Nothing
720+ Just chunk -> do
721+ maybes <- traverse f chunk
722+ let c = Array.mapMaybe identity maybes
723+ pure $ if Array.null c then Nothing else Just c
724+ pure $ Loop $ Tuple collected (collectMStrom f next)
725+
726+-- | Remove consecutive duplicates
727+changesStrom :: forall ctx err a. Eq a => Strom ctx err a -> Strom ctx err a
728+changesStrom stream = changesHelper Nothing stream
729+ where
730+ changesHelper lastSeen s = mkStrom do
731+ step <- runStrom s
732+ case step of
733+ Done Nothing -> pure $ Done Nothing
734+ Done (Just chunk) -> do
735+ let
736+ Tuple _ result = Array.foldl
737+ ( \(Tuple prev acc) curr ->
738+ if Just curr == prev then Tuple (Just curr) acc
739+ else Tuple (Just curr) (Array.snoc acc curr)
740+ )
741+ (Tuple lastSeen [])
742+ chunk
743+ pure $ Done $ if Array.null result then Nothing else Just result
744+ Loop (Tuple maybeChunk next) ->
745+ case maybeChunk of
746+ Nothing -> pure $ Loop $ Tuple Nothing (changesHelper lastSeen next)
747+ Just chunk -> do
748+ let
749+ Tuple newLast result = Array.foldl
750+ ( \(Tuple prev acc) curr ->
751+ if Just curr == prev then Tuple (Just curr) acc
752+ else Tuple (Just curr) (Array.snoc acc curr)
753+ )
754+ (Tuple lastSeen [])
755+ chunk
756+ if Array.null result then pure $ Loop $ Tuple Nothing (changesHelper newLast next)
757+ else pure $ Loop $ Tuple (Just result) (changesHelper newLast next)
758+
759+--------------------------------------------------------------------------------
760+-- Grouping
761+--------------------------------------------------------------------------------
762+
763+-- | Group elements into fixed-size arrays
764+groupedStrom :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err (Array a)
765+groupedStrom size stream
766+ | size <= 0 = empty
767+ | otherwise = groupedHelper [] stream
768+ where
769+ groupedHelper buffer s = mkStrom do
770+ step <- runStrom s
771+ case step of
772+ Done Nothing ->
773+ -- End of stream, emit remaining buffer if non-empty
774+ pure $ Done $ if Array.null buffer then Nothing else Just [ buffer ]
775+ Done (Just chunk) -> do
776+ -- Process final chunk with buffer
777+ let
778+ combined = buffer <> chunk
779+ groups = groupsOf size combined
780+ completeGroups = Array.dropEnd 1 groups
781+ lastGroup = case Array.last groups of
782+ Just g -> if Array.length g == size then [ g ] else []
783+ Nothing -> []
784+ remainder = case Array.last groups of
785+ Just g -> if Array.length g < size then g else []
786+ Nothing -> []
787+ if Array.null remainder then pure $ Done $ if Array.null (completeGroups <> lastGroup) then Nothing else Just (completeGroups <> lastGroup)
788+ else pure $ Done $ if Array.null (completeGroups <> [ remainder ]) then Nothing else Just (completeGroups <> [ remainder ])
789+ Loop (Tuple maybeChunk next) ->
790+ case maybeChunk of
791+ Nothing -> pure $ Loop $ Tuple Nothing (groupedHelper buffer next)
792+ Just chunk -> do
793+ let
794+ combined = buffer <> chunk
795+ groups = groupsOf size combined
796+ completeGroups = if Array.null groups then [] else Array.dropEnd 1 groups
797+ newBuffer = case Array.last groups of
798+ Just g -> if Array.length g < size then g else []
799+ Nothing -> []
800+ if Array.null completeGroups then pure $ Loop $ Tuple Nothing (groupedHelper newBuffer next)
801+ else pure $ Loop $ Tuple (Just completeGroups) (groupedHelper newBuffer next)
802+
803+ groupsOf :: forall b. Int -> Array b -> Array (Array b)
804+ groupsOf n arr
805+ | n <= 0 || Array.null arr = []
806+ | otherwise =
807+ let
808+ group = Array.take n arr
809+ rest = Array.drop n arr
810+ in
811+ if Array.null group then [] else Array.cons group (groupsOf n rest)
812+
813+-- | Alias for groupedStrom
814+chunkedStrom :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err (Array a)
815+chunkedStrom = groupedStrom
816+
817+-- | Group consecutive elements by a key function
818+-- | Emits groups when the key changes
819+groupByStrom :: forall ctx err a k. Eq k => (a -> k) -> Strom ctx err a -> Strom ctx err (Array a)
820+groupByStrom keyFn stream = groupByHelper Nothing [] stream
821+ where
822+ groupByHelper lastKey buffer s = mkStrom do
823+ step <- runStrom s
824+ case step of
825+ Done Nothing ->
826+ pure $ Done $ if Array.null buffer then Nothing else Just [ buffer ]
827+ Done (Just chunk) -> do
828+ let
829+ Tuple _ result = Array.foldl
830+ ( \(Tuple currentKey acc) item ->
831+ let
832+ itemKey = keyFn item
833+ in
834+ case currentKey of
835+ Nothing -> Tuple (Just itemKey) { groups: [], buffer: [ item ] }
836+ Just k ->
837+ if k == itemKey then Tuple (Just itemKey) { groups: acc.groups, buffer: Array.snoc acc.buffer item }
838+ else Tuple (Just itemKey) { groups: Array.snoc acc.groups acc.buffer, buffer: [ item ] }
839+ )
840+ (Tuple lastKey { groups: [], buffer })
841+ chunk
842+ allGroups =
843+ if Array.null result.buffer then result.groups
844+ else Array.snoc result.groups result.buffer
845+ pure $ Done $ if Array.null allGroups then Nothing else Just allGroups
846+ Loop (Tuple maybeChunk next) ->
847+ case maybeChunk of
848+ Nothing -> pure $ Loop $ Tuple Nothing (groupByHelper lastKey buffer next)
849+ Just chunk -> do
850+ let
851+ Tuple newKey result = Array.foldl
852+ ( \(Tuple currentKey acc) item ->
853+ let
854+ itemKey = keyFn item
855+ in
856+ case currentKey of
857+ Nothing -> Tuple (Just itemKey) { groups: [], buffer: [ item ] }
858+ Just k ->
859+ if k == itemKey then Tuple (Just itemKey) { groups: acc.groups, buffer: Array.snoc acc.buffer item }
860+ else Tuple (Just itemKey) { groups: Array.snoc acc.groups acc.buffer, buffer: [ item ] }
861+ )
862+ (Tuple lastKey { groups: [], buffer })
863+ chunk
864+ if Array.null result.groups then pure $ Loop $ Tuple Nothing (groupByHelper newKey result.buffer next)
865+ else pure $ Loop $ Tuple (Just result.groups) (groupByHelper newKey result.buffer next)
866+
867+-- | Partition a stream by a predicate into two separate operations
868+-- | Returns a tuple of (trues, falses)
869+-- | Note: This requires running the stream twice, so it's not lazy
870+partition :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Tuple (Strom ctx err a) (Strom ctx err a)
871+partition predicate stream =
872+ Tuple
873+ (filterStrom predicate stream)
874+ (filterStrom (not <<< predicate) stream)
875+
876+-- | Partition and map in one pass
877+partitionMap :: forall ctx err a b c. (a -> Either b c) -> Strom ctx err a -> Tuple (Strom ctx err b) (Strom ctx err c)
878+partitionMap f stream =
879+ let
880+ leftOnly x = case f x of
881+ Left b -> Just b
882+ Right _ -> Nothing
883+ rightOnly x = case f x of
884+ Left _ -> Nothing
885+ Right c -> Just c
886+ in
887+ Tuple
888+ (collectStrom leftOnly stream)
889+ (collectStrom rightOnly stream)
890+
891+--------------------------------------------------------------------------------
892+-- Timing
893+--------------------------------------------------------------------------------
894+
895+-- | Debounce a stream - adds a delay after each element
896+-- | Useful for rate-limiting or giving downstream time to process
897+debounce :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
898+debounce duration stream = mkStrom do
899+ step <- runStrom stream
900+ case step of
901+ Done Nothing -> pure $ Done Nothing
902+ Done (Just chunk) -> do
903+ Om.delay duration
904+ pure $ Done $ Just chunk
905+ Loop (Tuple maybeChunk next) -> do
906+ case maybeChunk of
907+ Nothing -> pure $ Loop $ Tuple Nothing (debounce duration next)
908+ Just chunk -> do
909+ Om.delay duration
910+ pure $ Loop $ Tuple (Just chunk) (debounce duration next)
911+
912+-- | Throttle a stream - emit at most once per duration period
913+-- | Tracks last emission time and skips elements that arrive too quickly
914+-- | Note: Uses wall-clock time, not processing time
915+throttle :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
916+throttle (Milliseconds duration) stream = throttleWithState 0.0 stream
917+ where
918+ throttleWithState lastEmit s = mkStrom do
919+ step <- runStrom s
920+ case step of
921+ Done Nothing -> pure $ Done Nothing
922+ Done (Just chunk) -> do
923+ Tuple newLastEmit filtered <- filterByTime lastEmit chunk
924+ pure $ Done $ if Array.null filtered then Nothing else Just filtered
925+ Loop (Tuple maybeChunk next) -> do
926+ case maybeChunk of
927+ Nothing -> pure $ Loop $ Tuple Nothing (throttleWithState lastEmit next)
928+ Just chunk -> do
929+ Tuple newLastEmit filtered <- filterByTime lastEmit chunk
930+ if Array.null filtered then pure $ Loop $ Tuple Nothing (throttleWithState newLastEmit next)
931+ else pure $ Loop $ Tuple (Just filtered) (throttleWithState newLastEmit next)
932+
933+ filterByTime lastEmit chunk = do
934+ Tuple newLastEmit results <- Array.foldl
935+ ( \acc value -> do
936+ Tuple currentLast filtered <- acc
937+ currentTime <- liftEffect now
938+ let Milliseconds currentMillis = unInstant currentTime
939+ let elapsed = currentMillis - currentLast
940+ if elapsed >= duration then
941+ pure $ Tuple currentMillis (Array.snoc filtered value)
942+ else
943+ pure $ Tuple currentLast filtered
944+ )
945+ (pure $ Tuple lastEmit [])
946+ chunk
947+ pure $ Tuple newLastEmit results
948+
949+-- | Delay each element by a duration
950+delayStrom :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
951+delayStrom duration stream = mkStrom do
952+ Om.delay duration
953+ runStrom stream
954+
955+--------------------------------------------------------------------------------
956+-- Running
957+--------------------------------------------------------------------------------
958+
959+-- | Fold over all elements
960+runFold :: forall ctx err a b. b -> (b -> a -> b) -> Strom ctx err a -> Om ctx err b
961+runFold initial f stream = tailRecM go (Tuple initial stream)
962+ where
963+ go (Tuple acc s) = do
964+ step <- runStrom s
965+ case step of
966+ Done Nothing -> pure $ Done acc
967+ Done (Just chunk) -> pure $ Done $ foldl f acc chunk
968+ Loop (Tuple maybeChunk next) ->
969+ -- Process chunk if present and continue
970+ case maybeChunk of
971+ Nothing -> pure $ Loop $ Tuple acc next
972+ Just chunk -> pure $ Loop $ Tuple (foldl f acc chunk) next
973+
974+-- | Collect all elements into an array (O(n) using chunk accumulation)
975+runCollect :: forall ctx err a. Strom ctx err a -> Om ctx err (Array a)
976+runCollect stream = tailRecM go (Tuple List.Nil stream)
977+ where
978+ go (Tuple chunkList s) = do
979+ step <- runStrom s
980+ case step of
981+ Done Nothing -> pure $ Done $ Array.concat $ Array.fromFoldable $ List.reverse chunkList
982+ Done (Just chunk) -> pure $ Done $ Array.concat $ Array.fromFoldable $ List.reverse $ List.Cons chunk chunkList
983+ Loop (Tuple maybeChunk next) ->
984+ -- Emit the chunk (if present) and continue
985+ case maybeChunk of
986+ Nothing -> pure $ Loop $ Tuple chunkList next
987+ Just chunk -> pure $ Loop $ Tuple (List.Cons chunk chunkList) next
988+
989+-- | Run the stream and discard all elements
990+runDrain :: forall ctx err a. Strom ctx err a -> Om ctx err Unit
991+runDrain = runFold unit (\_ _ -> unit)
992+
993+-- | Traverse the stream with effects, discarding results
994+traverseStrom_ :: forall ctx err a. (a -> Om ctx err Unit) -> Strom ctx err a -> Om ctx err Unit
995+traverseStrom_ f stream = tailRecM go stream
996+ where
997+ go s = do
998+ step <- runStrom s
999+ case step of
1000+ Done Nothing -> pure $ Done unit
1001+ Done (Just chunk) -> do
1002+ _ <- traverse f chunk
1003+ pure $ Done unit
1004+ Loop (Tuple maybeChunk next) -> do
1005+ -- Process chunk if present, then continue
1006+ case maybeChunk of
1007+ Nothing -> pure unit
1008+ Just chunk -> void $ traverse f chunk
1009+ pure $ Loop next
1010+
1011+-- | Alias for traverseStrom_ with arguments flipped
1012+forStrom_ :: forall ctx err a. Strom ctx err a -> (a -> Om ctx err Unit) -> Om ctx err Unit
1013+forStrom_ = flip traverseStrom_
1014+
1015+-- | Alias for traverseStrom_ (FP convention)
1016+forMStrom_ :: forall ctx err a. Strom ctx err a -> (a -> Om ctx err Unit) -> Om ctx err Unit
1017+forMStrom_ = forStrom_
1018+
1019+-- | Alias for traverseStrom_ (explicit monadic naming)
1020+traverseMStrom_ :: forall ctx err a. (a -> Om ctx err Unit) -> Strom ctx err a -> Om ctx err Unit
1021+traverseMStrom_ = traverseStrom_
1022+
1023+--------------------------------------------------------------------------------
1024+-- Cancellable Execution
1025+--------------------------------------------------------------------------------
1026+
1027+-- | Run a stream in the background, returning a Fiber that can be cancelled
1028+-- |
1029+-- | To use cancellable streams, convert your stream to Om and use Om.launchOm:
1030+-- |
1031+-- | Example:
1032+-- | ```purescript
1033+-- | fiber <- Om.launchOm ctx handlers (Strom.runCollect myStream)
1034+-- | -- ... later ...
1035+-- | killFiber (error "Cancelled") fiber
1036+-- | ```
1037+-- |
1038+-- | Or for subscription-style:
1039+-- | ```purescript
1040+-- | fiber <- Om.launchOm ctx handlers (Strom.traverseStrom_ callback myStream)
1041+-- | -- ... later ...
1042+-- | killFiber (error "Unsubscribed") fiber
1043+-- | ```
1044+-- |
1045+-- | Note: You can use Om.launchOm directly with any Om-returning function like:
1046+-- | - `runCollect` - collect results in background
1047+-- | - `runDrain` - run for side effects
1048+-- | - `traverseStrom_` - process each element with callback
1049+-- | - `runFold` - fold with accumulator
1050+
1051+--------------------------------------------------------------------------------
1052+-- Combining
1053+--------------------------------------------------------------------------------
1054+
1055+-- | Append two streams
1056+-- | Now properly handles multi-chunk streams with new Step protocol
1057+-- | Note: s2 is appended TO s1 (s1 comes first, then s2)
1058+-- | This matches pipeline semantics: stream # appendStrom other means stream followed by other
1059+appendStrom :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1060+appendStrom toAppend baseStream = mkStrom do
1061+ step <- runStrom baseStream
1062+ case step of
1063+ Done Nothing -> runStrom toAppend
1064+ Done (Just chunk) -> pure $ Loop $ Tuple (Just chunk) toAppend -- Emit chunk and continue with toAppend
1065+ Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (appendStrom toAppend next)
1066+
1067+-- | Concatenate an array of streams
1068+-- | Uses foldr with flip to match appendStrom's pipeline-friendly parameter order
1069+concatStrom :: forall ctx err a. Array (Strom ctx err a) -> Strom ctx err a
1070+concatStrom streams = Array.foldr (flip appendStrom) empty streams
1071+
1072+-- | Zip two streams together
1073+zipStrom :: forall ctx err a b. Strom ctx err a -> Strom ctx err b -> Strom ctx err (Tuple a b)
1074+zipStrom = zipWithStrom Tuple
1075+
1076+-- | Zip two streams with a function
1077+zipWithStrom :: forall ctx err a b c. (a -> b -> c) -> Strom ctx err a -> Strom ctx err b -> Strom ctx err c
1078+zipWithStrom f s1 s2 = mkStrom do
1079+ step1 <- runStrom s1
1080+ step2 <- runStrom s2
1081+ case step1, step2 of
1082+ Done (Just arr1), Done (Just arr2) -> do
1083+ let zipped = Array.zipWith f arr1 arr2
1084+ pure $ Done $ Just zipped
1085+ Done Nothing, _ -> pure $ Done Nothing
1086+ _, Done Nothing -> pure $ Done Nothing
1087+ Loop (Tuple maybeChunk1 next1), Loop (Tuple maybeChunk2 next2) ->
1088+ -- Both looping: zip chunks if both present, continue with both nexts
1089+ case maybeChunk1, maybeChunk2 of
1090+ Just chunk1, Just chunk2 -> pure $ Loop $ Tuple (Just $ Array.zipWith f chunk1 chunk2) (zipWithStrom f next1 next2)
1091+ _, _ -> pure $ Loop $ Tuple Nothing (zipWithStrom f next1 next2)
1092+ Loop (Tuple _ next1), _ -> pure $ Loop $ Tuple Nothing (zipWithStrom f next1 s2)
1093+ _, Loop (Tuple _ next2) -> pure $ Loop $ Tuple Nothing (zipWithStrom f s1 next2)
1094+
1095+-- | Interleave two streams - alternates between elements from each stream
1096+-- | Similar to merge but deterministic - always alternates
1097+interleave :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1098+interleave s1 s2 = interleaveHelper true s1 s2
1099+ where
1100+ interleaveHelper preferFirst first second = mkStrom do
1101+ if preferFirst then do
1102+ step1 <- runStrom first
1103+ case step1 of
1104+ Done Nothing -> runStrom second
1105+ Done (Just chunk1) -> pure $ Loop $ Tuple (Just chunk1) (interleaveHelper false second first)
1106+ Loop (Tuple maybeChunk1 next1) ->
1107+ pure $ Loop $ Tuple maybeChunk1 (interleaveHelper false second next1)
1108+ else do
1109+ step2 <- runStrom second
1110+ case step2 of
1111+ Done Nothing -> runStrom first
1112+ Done (Just chunk2) -> pure $ Loop $ Tuple (Just chunk2) (interleaveHelper true first second)
1113+ Loop (Tuple maybeChunk2 next2) ->
1114+ pure $ Loop $ Tuple maybeChunk2 (interleaveHelper true first next2)
1115+
1116+-- | Intersperse a separator element between each element of the stream
1117+intersperse :: forall ctx err a. a -> Strom ctx err a -> Strom ctx err a
1118+intersperse separator stream = intersperseHelper true stream
1119+ where
1120+ intersperseHelper isFirst s = mkStrom do
1121+ step <- runStrom s
1122+ case step of
1123+ Done Nothing -> pure $ Done Nothing
1124+ Done (Just chunk) -> do
1125+ let
1126+ withSeparators =
1127+ if isFirst then Array.intercalate [ separator ] (Array.singleton <$> chunk)
1128+ else Array.cons separator (Array.intercalate [ separator ] (Array.singleton <$> chunk))
1129+ pure $ Done $ Just withSeparators
1130+ Loop (Tuple maybeChunk next) ->
1131+ case maybeChunk of
1132+ Nothing -> pure $ Loop $ Tuple Nothing (intersperseHelper isFirst next)
1133+ Just chunk -> do
1134+ let
1135+ withSeparators =
1136+ if isFirst then Array.intercalate [ separator ] (Array.singleton <$> chunk)
1137+ else Array.cons separator (Array.intercalate [ separator ] (Array.singleton <$> chunk))
1138+ pure $ Loop $ Tuple (Just withSeparators) (intersperseHelper false next)
1139+
1140+--------------------------------------------------------------------------------
1141+-- Buffering
1142+--------------------------------------------------------------------------------
1143+
1144+-- | Buffer stream chunks with backpressure (preserves chunking)
1145+bufferChunks :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a
1146+bufferChunks capacity stream
1147+ | capacity <= 0 = stream
1148+ | otherwise = mkStrom do
1149+ ctx <- Om.ask
1150+ liftAff do
1151+ bufferRef <- liftEffect $ Ref.new (CatQueue.empty :: CatQueue (BufferItem a))
1152+ sizeRef <- liftEffect $ Ref.new 0
1153+ mutex <- AVar.new unit
1154+ notEmpty <- AVar.empty
1155+ notFull <- AVar.new unit
1156+
1157+ let
1158+ enqueue item = do
1159+ AVar.take notFull
1160+ AVar.take mutex
1161+ size <- liftEffect $ Ref.read sizeRef
1162+ let wasEmpty = size == 0
1163+ buf <- liftEffect $ Ref.read bufferRef
1164+ liftEffect $ Ref.write (CatQueue.snoc buf item) bufferRef
1165+ liftEffect $ Ref.write (size + 1) sizeRef
1166+ AVar.put unit mutex
1167+ when wasEmpty $ AVar.put unit notEmpty
1168+ when (size + 1 < capacity) $ AVar.put unit notFull
1169+
1170+ dequeue = do
1171+ AVar.take notEmpty
1172+ AVar.take mutex
1173+ buf <- liftEffect $ Ref.read bufferRef
1174+ size <- liftEffect $ Ref.read sizeRef
1175+ case CatQueue.uncons buf of
1176+ Nothing -> do
1177+ AVar.put unit mutex
1178+ dequeue
1179+ Just (Tuple head tail) -> do
1180+ liftEffect $ Ref.write tail bufferRef
1181+ liftEffect $ Ref.write (size - 1) sizeRef
1182+ let hasMore = (size - 1) > 0
1183+ let wasFull = size == capacity
1184+ AVar.put unit mutex
1185+ when hasMore $ AVar.put unit notEmpty
1186+ when wasFull $ AVar.put unit notFull
1187+ pure head
1188+
1189+ producer s = do
1190+ stepResult <- Om.runReader ctx (runStrom s)
1191+ step <- either (\_ -> throwError (Exception.error "Stream error")) pure stepResult
1192+ case step of
1193+ Done Nothing -> enqueue BufferDone
1194+ Done (Just chunk) -> do
1195+ enqueue (BufferChunk chunk)
1196+ enqueue BufferDone
1197+ Loop (Tuple maybeChunk next) -> do
1198+ case maybeChunk of
1199+ Just chunk -> enqueue (BufferChunk chunk)
1200+ Nothing -> pure unit
1201+ producer next
1202+
1203+ fiber <- forkAff (producer stream)
1204+
1205+ let
1206+ consumer = mkStrom $ liftAff do
1207+ msg <- dequeue
1208+ case msg of
1209+ BufferDone -> do
1210+ killFiber (Exception.error "done") fiber
1211+ pure $ Done Nothing
1212+ BufferChunk chunk ->
1213+ pure $ Loop $ Tuple (Just chunk) consumer
1214+
1215+ stepResult <- Om.runReader ctx (runStrom consumer)
1216+ either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
1217+
1218+-- | Buffer stream elements with backpressure (destroys chunking)
1219+buffer :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a
1220+buffer capacity stream
1221+ | capacity <= 0 = stream
1222+ | otherwise = mkStrom do
1223+ ctx <- Om.ask
1224+ liftAff do
1225+ bufferRef <- liftEffect $ Ref.new (CatQueue.empty :: CatQueue (BufferItem a))
1226+ sizeRef <- liftEffect $ Ref.new 0
1227+ mutex <- AVar.new unit
1228+ notEmpty <- AVar.empty
1229+ notFull <- AVar.new unit
1230+
1231+ let
1232+ enqueue item = do
1233+ AVar.take notFull
1234+ AVar.take mutex
1235+ size <- liftEffect $ Ref.read sizeRef
1236+ let wasEmpty = size == 0
1237+ buf <- liftEffect $ Ref.read bufferRef
1238+ liftEffect $ Ref.write (CatQueue.snoc buf item) bufferRef
1239+ liftEffect $ Ref.write (size + 1) sizeRef
1240+ AVar.put unit mutex
1241+ when wasEmpty $ AVar.put unit notEmpty
1242+ when (size + 1 < capacity) $ AVar.put unit notFull
1243+
1244+ dequeue = do
1245+ AVar.take notEmpty
1246+ AVar.take mutex
1247+ buf <- liftEffect $ Ref.read bufferRef
1248+ size <- liftEffect $ Ref.read sizeRef
1249+ case CatQueue.uncons buf of
1250+ Nothing -> do
1251+ AVar.put unit mutex
1252+ dequeue
1253+ Just (Tuple head tail) -> do
1254+ liftEffect $ Ref.write tail bufferRef
1255+ liftEffect $ Ref.write (size - 1) sizeRef
1256+ let hasMore = (size - 1) > 0
1257+ let wasFull = size == capacity
1258+ AVar.put unit mutex
1259+ when hasMore $ AVar.put unit notEmpty
1260+ when wasFull $ AVar.put unit notFull
1261+ pure head
1262+
1263+ producer s = do
1264+ stepResult <- Om.runReader ctx (runStrom s)
1265+ step <- either (\_ -> throwError (Exception.error "Stream error")) pure stepResult
1266+ case step of
1267+ Done Nothing -> enqueue BufferDone
1268+ Done (Just chunk) -> do
1269+ traverse_ (\a -> enqueue (BufferChunk [ a ])) chunk
1270+ enqueue BufferDone
1271+ Loop (Tuple maybeChunk next) -> do
1272+ case maybeChunk of
1273+ Just chunk -> traverse_ (\a -> enqueue (BufferChunk [ a ])) chunk
1274+ Nothing -> pure unit
1275+ producer next
1276+
1277+ fiber <- forkAff (producer stream)
1278+
1279+ let
1280+ consumer = mkStrom $ liftAff do
1281+ msg <- dequeue
1282+ case msg of
1283+ BufferDone -> do
1284+ killFiber (Exception.error "done") fiber
1285+ pure $ Done Nothing
1286+ BufferChunk chunk ->
1287+ pure $ Loop $ Tuple (Just chunk) consumer
1288+
1289+ stepResult <- Om.runReader ctx (runStrom consumer)
1290+ either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
1291+
1292+--------------------------------------------------------------------------------
1293+-- Concurrency
1294+--------------------------------------------------------------------------------
1295+
1296+-- | Merge two streams concurrently (nondeterministic like ZStream merge)
1297+-- | Elements are emitted as soon as they're available from either stream
1298+merge :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1299+merge = mergeNDWith 1024
1300+
1301+-- | Merge with halt strategy and mapping functions (ZStream-like)
1302+mergeWith
1303+ :: forall ctx err a b c
1304+ . HaltStrategy
1305+ -> (a -> c)
1306+ -> (b -> c)
1307+ -> Strom ctx err a
1308+ -> Strom ctx err b
1309+ -> Strom ctx err c
1310+mergeWith strategy f g s1 s2 =
1311+ mergeNDWithStrategy 1024 strategy (mapStrom f s1) (mapStrom g s2)
1312+
1313+mergeHaltEither :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1314+mergeHaltEither = mergeNDWithStrategy 1024 HaltEither
1315+
1316+mergeHaltLeft :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1317+mergeHaltLeft = mergeNDWithStrategy 1024 HaltLeft
1318+
1319+mergeHaltRight :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1320+mergeHaltRight = mergeNDWithStrategy 1024 HaltRight
1321+
1322+-- | Merge multiple streams concurrently
1323+mergeAll :: forall ctx err a. Array (Strom ctx err a) -> Strom ctx err a
1324+mergeAll streams = Array.foldl merge empty streams
1325+
1326+-- | Race two streams - returns elements from whichever produces first
1327+-- | Uses Om.race for true concurrent racing
1328+race :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1329+race = mergeHaltEither
1330+
1331+-- | Race multiple streams - returns elements from whichever produces first
1332+raceAll :: forall ctx err a. Array (Strom ctx err a) -> Strom ctx err a
1333+raceAll streams =
1334+ case Array.uncons streams of
1335+ Nothing -> empty
1336+ Just { head, tail } -> Array.foldl mergeHaltEither head tail
1337+
1338+-- | Non-deterministic merge: Merge two streams concurrently, emitting elements as soon as they're available
1339+-- | Unlike `merge`, this runs both streams in parallel and elements may arrive in any order
1340+-- | The stream that produces results faster will have its elements appear first
1341+-- | Optimized: Spawns producer fibers once and uses a queue for communication
1342+mergeND :: forall ctx err a. Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1343+mergeND = mergeNDWith 1024
1344+
1345+-- | Non-deterministic merge with configurable buffer capacity (chunk queue)
1346+mergeNDWith :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a -> Strom ctx err a
1347+mergeNDWith bufferCapacity = mergeNDWithStrategy bufferCapacity HaltBoth
1348+
1349+mergeNDWithStrategy
1350+ :: forall ctx err a
1351+ . Int
1352+ -> HaltStrategy
1353+ -> Strom ctx err a
1354+ -> Strom ctx err a
1355+ -> Strom ctx err a
1356+mergeNDWithStrategy bufferCapacity strategy stream1 stream2 = mkStrom do
1357+ -- Capture context
1358+ ctx <- Om.ask
1359+
1360+ liftAff do
1361+ -- Create buffered queue with backpressure
1362+ bufferRef <- liftEffect $ Ref.new (CatQueue.empty :: CatQueue (QueueItem a))
1363+ sizeRef <- liftEffect $ Ref.new 0
1364+ mutex <- AVar.new unit
1365+ notEmpty <- AVar.empty
1366+ notFull <- AVar.new unit
1367+
1368+ let
1369+ shouldStop doneLeft doneRight =
1370+ case strategy of
1371+ HaltBoth -> doneLeft && doneRight
1372+ HaltEither -> doneLeft || doneRight
1373+ HaltLeft -> doneLeft
1374+ HaltRight -> doneRight
1375+ enqueue item = do
1376+ AVar.take notFull
1377+ AVar.take mutex
1378+ size <- liftEffect $ Ref.read sizeRef
1379+ let wasEmpty = size == 0
1380+ buf <- liftEffect $ Ref.read bufferRef
1381+ liftEffect $ Ref.write (CatQueue.snoc buf item) bufferRef
1382+ liftEffect $ Ref.write (size + 1) sizeRef
1383+ AVar.put unit mutex
1384+ when wasEmpty $ AVar.put unit notEmpty
1385+ when (size + 1 < bufferCapacity) $ AVar.put unit notFull
1386+
1387+ dequeue = do
1388+ AVar.take notEmpty
1389+ AVar.take mutex
1390+ buf <- liftEffect $ Ref.read bufferRef
1391+ size <- liftEffect $ Ref.read sizeRef
1392+ case CatQueue.uncons buf of
1393+ Nothing -> do
1394+ AVar.put unit mutex
1395+ dequeue
1396+ Just (Tuple head tail) -> do
1397+ liftEffect $ Ref.write tail bufferRef
1398+ liftEffect $ Ref.write (size - 1) sizeRef
1399+ let hasMore = (size - 1) > 0
1400+ let wasFull = size == bufferCapacity
1401+ AVar.put unit mutex
1402+ when hasMore $ AVar.put unit notEmpty
1403+ when wasFull $ AVar.put unit notFull
1404+ pure head
1405+
1406+ -- Spawn producer fiber for stream1
1407+ fiber1 <- forkAff do
1408+ let
1409+ producer s = do
1410+ stepResult <- Om.runReader ctx (runStrom s)
1411+ step <- either (\e -> throwError (Exception.error "Stream error")) pure stepResult
1412+ case step of
1413+ Done Nothing ->
1414+ enqueue (StreamDone StreamId1)
1415+ Done (Just chunk) -> do
1416+ enqueue (DataChunk chunk StreamId1)
1417+ enqueue (StreamDone StreamId1)
1418+ Loop (Tuple maybeChunk next) -> do
1419+ case maybeChunk of
1420+ Just chunk -> enqueue (DataChunk chunk StreamId1)
1421+ Nothing -> pure unit
1422+ producer next
1423+ producer stream1
1424+
1425+ -- Spawn producer fiber for stream2
1426+ fiber2 <- forkAff do
1427+ let
1428+ producer s = do
1429+ stepResult <- Om.runReader ctx (runStrom s)
1430+ step <- either (\e -> throwError (Exception.error "Stream error")) pure stepResult
1431+ case step of
1432+ Done Nothing ->
1433+ enqueue (StreamDone StreamId2)
1434+ Done (Just chunk) -> do
1435+ enqueue (DataChunk chunk StreamId2)
1436+ enqueue (StreamDone StreamId2)
1437+ Loop (Tuple maybeChunk next) -> do
1438+ case maybeChunk of
1439+ Just chunk -> enqueue (DataChunk chunk StreamId2)
1440+ Nothing -> pure unit
1441+ producer next
1442+ producer stream2
1443+
1444+ -- Consumer: pull from queue
1445+ let
1446+ consumer doneLeft doneRight = mkStrom $ liftAff do
1447+ if shouldStop doneLeft doneRight then do
1448+ -- Both streams done, cleanup
1449+ killFiber (Exception.error "done") fiber1
1450+ killFiber (Exception.error "done") fiber2
1451+ pure $ Done Nothing
1452+ else do
1453+ msg <- dequeue
1454+ case msg of
1455+ StreamDone streamId -> do
1456+ -- One stream finished, recurse
1457+ let
1458+ doneLeft' = doneLeft || streamId == StreamId1
1459+ doneRight' = doneRight || streamId == StreamId2
1460+ stepResult <- Om.runReader ctx (runStrom $ consumer doneLeft' doneRight')
1461+ either (\_ -> throwError (Exception.error "Consumer error")) pure stepResult
1462+ DataChunk chunk _streamId ->
1463+ -- Got a chunk
1464+ pure $ Loop $ Tuple (Just chunk) (consumer doneLeft doneRight)
1465+
1466+ stepResult <- Om.runReader ctx (runStrom $ consumer false false)
1467+ either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
1468+
1469+-- | Non-deterministic merge of multiple streams
1470+-- | All streams run concurrently and emit elements as soon as they're available
1471+mergeAllND :: forall ctx err a. Array (Strom ctx err a) -> Strom ctx err a
1472+mergeAllND = mergeAllNDWith 1024
1473+
1474+-- | Non-deterministic merge of multiple streams with configurable buffer capacity
1475+mergeAllNDWith :: forall ctx err a. Int -> Array (Strom ctx err a) -> Strom ctx err a
1476+mergeAllNDWith bufferCapacity streams
1477+ | Array.null streams = empty
1478+ | otherwise = mkStrom do
1479+ ctx <- Om.ask
1480+ liftAff do
1481+ bufferRef <- liftEffect $ Ref.new (CatQueue.empty :: CatQueue (MergeAllItem a))
1482+ sizeRef <- liftEffect $ Ref.new 0
1483+ mutex <- AVar.new unit
1484+ notEmpty <- AVar.empty
1485+ notFull <- AVar.new unit
1486+
1487+ let
1488+ enqueue item = do
1489+ AVar.take notFull
1490+ AVar.take mutex
1491+ size <- liftEffect $ Ref.read sizeRef
1492+ let wasEmpty = size == 0
1493+ buf <- liftEffect $ Ref.read bufferRef
1494+ liftEffect $ Ref.write (CatQueue.snoc buf item) bufferRef
1495+ liftEffect $ Ref.write (size + 1) sizeRef
1496+ AVar.put unit mutex
1497+ when wasEmpty $ AVar.put unit notEmpty
1498+ when (size + 1 < bufferCapacity) $ AVar.put unit notFull
1499+
1500+ dequeue = do
1501+ AVar.take notEmpty
1502+ AVar.take mutex
1503+ buf <- liftEffect $ Ref.read bufferRef
1504+ size <- liftEffect $ Ref.read sizeRef
1505+ case CatQueue.uncons buf of
1506+ Nothing -> do
1507+ AVar.put unit mutex
1508+ dequeue
1509+ Just (Tuple head tail) -> do
1510+ liftEffect $ Ref.write tail bufferRef
1511+ liftEffect $ Ref.write (size - 1) sizeRef
1512+ let hasMore = (size - 1) > 0
1513+ let wasFull = size == bufferCapacity
1514+ AVar.put unit mutex
1515+ when hasMore $ AVar.put unit notEmpty
1516+ when wasFull $ AVar.put unit notFull
1517+ pure head
1518+
1519+ producer s = do
1520+ stepResult <- Om.runReader ctx (runStrom s)
1521+ step <- either (\_ -> throwError (Exception.error "Stream error")) pure stepResult
1522+ case step of
1523+ Done Nothing -> enqueue MergeDone
1524+ Done (Just chunk) -> do
1525+ enqueue (MergeChunk chunk)
1526+ enqueue MergeDone
1527+ Loop (Tuple maybeChunk next) -> do
1528+ case maybeChunk of
1529+ Just chunk -> enqueue (MergeChunk chunk)
1530+ Nothing -> pure unit
1531+ producer next
1532+
1533+ fibers <- traverse (\s -> forkAff (producer s)) streams
1534+
1535+ let
1536+ totalStreams = Array.length streams
1537+ consumer doneCount = mkStrom $ liftAff do
1538+ if doneCount >= totalStreams then do
1539+ traverse_ (\fiber -> killFiber (Exception.error "done") fiber) fibers
1540+ pure $ Done Nothing
1541+ else do
1542+ msg <- dequeue
1543+ case msg of
1544+ MergeDone -> do
1545+ stepResult <- Om.runReader ctx (runStrom $ consumer (doneCount + 1))
1546+ either (\_ -> throwError (Exception.error "Consumer error")) pure stepResult
1547+ MergeChunk chunk ->
1548+ pure $ Loop $ Tuple (Just chunk) (consumer doneCount)
1549+
1550+ stepResult <- Om.runReader ctx (runStrom $ consumer 0)
1551+ either (\_ -> throwError (Exception.error "Consumer init error")) pure stepResult
1552+
1553+--------------------------------------------------------------------------------
1554+-- Parallel Processing
1555+--------------------------------------------------------------------------------
1556+
1557+-- | Map over elements in parallel with bounded concurrency
1558+-- | Processes chunks in parallel groups up to the concurrency limit
1559+mapPar :: forall ctx err a b. Int -> (a -> Om ctx err b) -> Strom ctx err a -> Strom ctx err b
1560+mapPar concurrency f stream
1561+ | concurrency <= 1 = mapMStrom f stream -- Sequential fallback
1562+ | otherwise =
1563+ mkStrom do
1564+ step <- runStrom stream
1565+ case step of
1566+ Done Nothing -> pure $ Done Nothing
1567+ Done (Just chunk) -> do
1568+ -- Split chunk into groups of `concurrency` size and process each group in parallel
1569+ let groups = chunksOf concurrency chunk
1570+ results <- traverse (\group -> Om.inParallel (Functor.map f group)) groups
1571+ pure $ Done $ Just $ Array.concat results
1572+ Loop (Tuple maybeChunk next) -> do
1573+ mappedChunk <- case maybeChunk of
1574+ Nothing -> pure Nothing
1575+ Just chunk -> do
1576+ let groups = chunksOf concurrency chunk
1577+ results <- traverse (\group -> Om.inParallel (Functor.map f group)) groups
1578+ pure $ Just $ Array.concat results
1579+ pure $ Loop $ Tuple mappedChunk (mapPar concurrency f next)
1580+ where
1581+ chunksOf :: forall x. Int -> Array x -> Array (Array x)
1582+ chunksOf n arr
1583+ | n <= 0 || Array.null arr = []
1584+ | otherwise =
1585+ let
1586+ group = Array.take n arr
1587+ rest = Array.drop n arr
1588+ in
1589+ if Array.null group then [] else Array.cons group (chunksOf n rest)
1590+
1591+-- | Unordered parallel map (currently same as mapPar)
1592+mapParUnordered :: forall ctx err a b. Int -> (a -> Om ctx err b) -> Strom ctx err a -> Strom ctx err b
1593+mapParUnordered = mapPar
1594+
1595+-- | Alias for mapPar with explicit "M" naming
1596+mapMPar :: forall ctx err a b. Int -> (a -> Om ctx err b) -> Strom ctx err a -> Strom ctx err b
1597+mapMPar = mapPar
1598+
1599+-- | Alias for unordered mapPar with explicit "M" naming
1600+mapMParUnordered :: forall ctx err a b. Int -> (a -> Om ctx err b) -> Strom ctx err a -> Strom ctx err b
1601+mapMParUnordered = mapParUnordered
1602+
1603+-- | For each element, execute an effect in parallel with bounded concurrency
1604+foreachPar :: forall ctx err a. Int -> (a -> Om ctx err Unit) -> Strom ctx err a -> Om ctx err Unit
1605+foreachPar concurrency f stream = runDrain (mapPar concurrency f stream)
1606+
1607+-- | Unordered parallel foreach
1608+foreachParUnordered :: forall ctx err a. Int -> (a -> Om ctx err Unit) -> Strom ctx err a -> Om ctx err Unit
1609+foreachParUnordered concurrency f stream = runDrain (mapParUnordered concurrency f stream)
1610+
1611+--------------------------------------------------------------------------------
1612+-- Error Handling
1613+--------------------------------------------------------------------------------
1614+
1615+-- | Catch all errors and recover with a handler function
1616+-- | Note: Uses handleErrors' for full error handling
1617+catchAll :: forall ctx err a. (Variant (exception :: Error | err) -> Strom ctx err a) -> Strom ctx err a -> Strom ctx err a
1618+catchAll handler stream = mkStrom do
1619+ Om.handleErrors'
1620+ (\err -> runStrom (handler err))
1621+ (runStrom stream)
1622+
1623+-- TODO: Fix catchSome type constraints
1624+-- catchSome :: forall ctx err handlers a. (Record handlers) -> Strom ctx err a -> Strom ctx err a
1625+-- catchSome handlers stream = mkStrom do
1626+-- Om.handleErrors handlers (runStrom stream)
1627+
1628+-- | Retry a stream on failure up to 3 times
1629+retry :: forall ctx err a. Strom ctx err a -> Strom ctx err a
1630+retry = retryN 3
1631+
1632+-- | Retry a stream N times on failure
1633+retryN :: forall ctx err a. Int -> Strom ctx err a -> Strom ctx err a
1634+retryN n stream
1635+ | n <= 0 = stream
1636+ | otherwise = catchAll (\_ -> retryN (n - 1) stream) stream
1637+
1638+-- | Run a finaliser after the stream completes (success or failure)
1639+ensuring :: forall ctx err a. Om ctx err Unit -> Strom ctx err a -> Strom ctx err a
1640+ensuring finaliser stream = mkStrom do
1641+ result <- Om.handleErrors'
1642+ ( \err -> do
1643+ finaliser
1644+ throwError err
1645+ )
1646+ ( runStrom stream >>= \step -> do
1647+ finaliser
1648+ pure step
1649+ )
1650+ pure result
1651+
1652+-- | Add a timeout to stream operations
1653+-- | If the stream doesn't complete within the duration, it terminates as empty
1654+timeout :: forall ctx err a. Milliseconds -> Strom ctx err a -> Strom ctx err a
1655+timeout duration stream = mkStrom do
1656+ Om.race [ runStrom stream, Om.delay duration *> runStrom empty ]
1657+
1658+--------------------------------------------------------------------------------
1659+-- Resource Safety
1660+--------------------------------------------------------------------------------
1661+
1662+-- | Bracket pattern: acquire, use, release
1663+-- | Guarantees release runs even if stream fails
1664+bracket
1665+ :: forall ctx err a b
1666+ . Om ctx err a -- acquire
1667+ -> (a -> Om ctx err Unit) -- release
1668+ -> (a -> Strom ctx err b) -- use
1669+ -> Strom ctx err b
1670+bracket acquire release use = mkStrom do
1671+ resource <- acquire
1672+ Om.handleErrors'
1673+ ( \err -> do
1674+ release resource
1675+ throwError err
1676+ )
1677+ (runStrom (use resource))
1678+ >>= \step -> do
1679+ case step of
1680+ Done _ -> release resource
1681+ Loop _ -> pure unit
1682+ pure step
1683+
1684+-- | Bracket with exit information (whether stream succeeded or failed)
1685+bracketExit
1686+ :: forall ctx err a b
1687+ . Om ctx err a -- acquire
1688+ -> (a -> Maybe (Variant (exception :: Error | err)) -> Om ctx err Unit) -- release with exit info
1689+ -> (a -> Strom ctx err b) -- use
1690+ -> Strom ctx err b
1691+bracketExit acquire release use = mkStrom do
1692+ resource <- acquire
1693+ Om.handleErrors'
1694+ ( \err -> do
1695+ release resource (Just err)
1696+ throwError err
1697+ )
1698+ (runStrom (use resource))
1699+ >>= \step -> do
1700+ case step of
1701+ Done _ -> release resource Nothing
1702+ Loop _ -> pure unit
1703+ pure step
1704+
1705+-- | Simplified acquire-release pattern
1706+acquireRelease
1707+ :: forall ctx err a b
1708+ . Om ctx err a -- acquire
1709+ -> (a -> Om ctx err Unit) -- release
1710+ -> (a -> Strom ctx err b) -- use
1711+ -> Strom ctx err b
1712+acquireRelease = bracket
1713+
1714+--------------------------------------------------------------------------------
1715+-- Instances
1716+--------------------------------------------------------------------------------
1717+
1718+instance Functor (Strom ctx err) where
1719+ map f stream = mkStrom
1720+ ( runStrom stream >>= \step ->
1721+ case step of
1722+ Done Nothing -> pure $ Done Nothing
1723+ Done (Just chunk) -> pure $ Done $ Just $ Functor.map f chunk
1724+ Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple (Functor.map (Functor.map f) maybeChunk) (Functor.map f next)
1725+ )
1726+
1727+instance Apply (Strom ctx err) where
1728+ apply fs as = mkStrom
1729+ ( runStrom fs >>= \stepF ->
1730+ runStrom as >>= \stepA ->
1731+ case stepF, stepA of
1732+ Done (Just funcs), Done (Just vals) ->
1733+ let
1734+ results = do
1735+ f <- funcs
1736+ a <- vals
1737+ pure (f a)
1738+ in
1739+ pure $ Done $ Just results
1740+ Done Nothing, _ -> pure $ Done Nothing
1741+ _, Done Nothing -> pure $ Done Nothing
1742+ Loop (Tuple maybeChunkF nextF), Loop (Tuple maybeChunkA nextA) ->
1743+ -- Apply chunks if both present
1744+ case maybeChunkF, maybeChunkA of
1745+ Just chunkF, Just chunkA ->
1746+ let
1747+ applied = do
1748+ f <- chunkF
1749+ a <- chunkA
1750+ pure (f a)
1751+ in
1752+ pure $ Loop $ Tuple (Just applied) (nextF <*> nextA)
1753+ _, _ -> pure $ Loop $ Tuple Nothing (nextF <*> nextA)
1754+ Loop (Tuple _ nextF), _ -> pure $ Loop $ Tuple Nothing (nextF <*> as)
1755+ _, Loop (Tuple _ nextA) -> pure $ Loop $ Tuple Nothing (fs <*> nextA)
1756+ )
1757+
1758+instance Applicative (Strom ctx err) where
1759+ pure = succeed
1760+
1761+instance Bind (Strom ctx err) where
1762+ bind stream f = mkStrom
1763+ ( runStrom stream >>= \step ->
1764+ case step of
1765+ Done Nothing -> pure $ Done Nothing
1766+ Done (Just chunk) ->
1767+ let
1768+ streams = Functor.map f chunk
1769+ in
1770+ runStrom $ concatStrom streams
1771+ Loop (Tuple maybeChunk next) ->
1772+ -- When looping, if we have a chunk, we need to process it through f and concat
1773+ case maybeChunk of
1774+ Nothing -> pure $ Loop $ Tuple Nothing (next >>= f)
1775+ Just chunk ->
1776+ let
1777+ streams = Functor.map f chunk
1778+ prefixStream = concatStrom streams
1779+ in
1780+ runStrom $ appendStrom prefixStream (next >>= f)
1781+ )
1782+
1783+instance Monad (Strom ctx err)
1784+
1785+instance Semigroup (Strom ctx err a) where
1786+ append s1 s2 = mkStrom
1787+ ( runStrom s1 >>= \step ->
1788+ case step of
1789+ Done Nothing -> runStrom s2
1790+ Done (Just chunk) -> pure $ Loop $ Tuple (Just chunk) s2
1791+ Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (next <> s2)
1792+ )
1793+
1794+instance Monoid (Strom ctx err a) where
1795+ mempty = empty
1796+
1797+instance Alt (Strom ctx err) where
1798+ alt s1 s2 = mkStrom
1799+ ( runStrom s1 >>= \step ->
1800+ case step of
1801+ Done Nothing -> runStrom s2
1802+ Done (Just chunk) -> pure $ Loop $ Tuple (Just chunk) s2
1803+ Loop (Tuple maybeChunk next) -> pure $ Loop $ Tuple maybeChunk (next <|> s2)
1804+ )
1805+
1806+instance Plus (Strom ctx err) where
1807+ empty = mkStrom $ pure $ Done Nothing
1808+
1809+instance Alternative (Strom ctx err)
src/Yoga/Om/Strom.test.purs+3−3
@@ -10,9 +10,9 @@ import Data.Time.Duration (Milliseconds(..))
1010import Effect.Aff (Aff)
1111import Effect.Ref as Ref
1212import Effect.Class (liftEffect)
13−import Effect.Exception (throwException, error)
13+import Effect.Exception (throwException)
1414import Test.Spec (Spec, describe, it)
15−import Test.Spec.Assertions (shouldEqual, fail)
15+import Test.Spec.Assertions (shouldEqual)
1616import Yoga.Om (Om)
1717import Yoga.Om as Om
1818import Yoga.Om.Strom as Strom
@@ -172,7 +172,7 @@ spec = do
172172 result `shouldEqual` [ "1a", "2b", "3c" ]
173173
174174 it "tap observes without modifying" do
175− sideEffectRef <- liftEffect $ Ref.new 0
175+ _sideEffectRef <- liftEffect $ Ref.new 0
176176 result <- runOm $
177177 Strom.fromArray [ 1, 2, 3 ]
178178 # Strom.tapStrom (\n -> let _ = n in unit) -- Pure observation
src/Yoga/Om/Strom/Examples.purs+3−3
@@ -106,7 +106,7 @@ example4b = do
106106example5 :: Om {} () (Array (Tuple Int String))
107107example5 = do
108108 let numbers = Strom.rangeStrom 1 5
109− let letters = Strom.fromArray ["a", "b", "c", "d", "e"]
109+ let _letters = Strom.fromArray ["a", "b", "c", "d", "e"]
110110 -- TODO: zipStrom not yet implemented
111111 numbers # Strom.mapStrom (\n -> Tuple n "stub") # Strom.runCollect
112112
@@ -288,11 +288,11 @@ example11 = do
288288 Om.delay (Milliseconds 100.0)
289289 pure 1
290290
291− source2 = Strom.fromOm do
291+ _source2 = Strom.fromOm do
292292 Om.delay (Milliseconds 50.0) -- This will win
293293 pure 2
294294
295− source3 = Strom.fromOm do
295+ _source3 = Strom.fromOm do
296296 Om.delay (Milliseconds 200.0)
297297 pure 3
298298
src/Yoga/Om/Strom/FileStream.jsnew file+17
@@ -0,0 +1,17 @@
1+import * as fs from "node:fs";
2+
3+export const _writeFile = (path) => (content) => () => {
4+ fs.writeFileSync(path, content);
5+};
6+
7+export const _appendFile = (path) => (content) => () => {
8+ fs.appendFileSync(path, content);
9+};
10+
11+export const _readFile = (path) => () => {
12+ return fs.readFileSync(path, "utf-8");
13+};
14+
15+export const _unlinkFile = (path) => () => {
16+ try { fs.unlinkSync(path); } catch (_) {}
17+};
src/Yoga/Om/Strom/WebStream.jsnew file+176
@@ -0,0 +1,176 @@
1+// FFI bindings for Web Streams API interop
2+
3+// Create a ReadableStream with a pull-based source
4+// pullFn :: Controller -> Effect Promise
5+export const _newReadableStream = (pullFn) => (cancelFn) => () => {
6+ return new ReadableStream({
7+ pull(controller) {
8+ return pullFn(controller)();
9+ },
10+ cancel() {
11+ cancelFn();
12+ }
13+ });
14+};
15+
16+// Enqueue a value into a ReadableStreamDefaultController
17+export const _enqueue = (controller) => (value) => () => {
18+ controller.enqueue(value);
19+};
20+
21+// Close a ReadableStreamDefaultController
22+export const _closeController = (controller) => () => {
23+ controller.close();
24+};
25+
26+// Signal an error on a ReadableStreamDefaultController
27+export const _errorController = (controller) => (err) => () => {
28+ controller.error(err);
29+};
30+
31+// Create a Promise from resolve/reject callbacks.
32+// Takes an Effect-wrapped function that receives resolve and reject as Effect callbacks.
33+// makePromise :: ((a -> Effect Unit) -> (Error -> Effect Unit) -> Effect Unit) -> Effect (Promise a)
34+export const _makePromise = (k) => () => {
35+ return new Promise((resolve, reject) => {
36+ k((a) => () => resolve(a))((e) => () => reject(e))();
37+ });
38+};
39+
40+// Get a reader from a ReadableStream
41+export const _getReader = (stream) => () => {
42+ return stream.getReader();
43+};
44+
45+// Release the reader lock
46+export const _releaseReader = (reader) => () => {
47+ reader.releaseLock();
48+};
49+
50+// Get a writer from a WritableStream
51+export const _getWriter = (stream) => () => {
52+ return stream.getWriter();
53+};
54+
55+// Write a value using a writer — returns EffectFnAff
56+export const _writeWriterImpl = (writer) => (value) => (onError, onSuccess) => {
57+ writer.write(value).then(
58+ () => onSuccess(),
59+ (err) => onError(err)
60+ );
61+ return (cancelError, onCancelerError, onCancelerSuccess) => {
62+ onCancelerSuccess();
63+ };
64+};
65+
66+// Close a writer — returns EffectFnAff
67+export const _closeWriterImpl = (writer) => (onError, onSuccess) => {
68+ writer.close().then(
69+ () => onSuccess(),
70+ (err) => onError(err)
71+ );
72+ return (cancelError, onCancelerError, onCancelerSuccess) => {
73+ onCancelerSuccess();
74+ };
75+};
76+
77+// Wait for writer.ready — returns EffectFnAff
78+export const _writerReadyImpl = (writer) => (onError, onSuccess) => {
79+ writer.ready.then(
80+ () => onSuccess(),
81+ (err) => onError(err)
82+ );
83+ return (cancelError, onCancelerError, onCancelerSuccess) => {
84+ onCancelerSuccess();
85+ };
86+};
87+
88+// Create a new TransformStream
89+export const _newTransformStream = () => {
90+ return new TransformStream();
91+};
92+
93+// Get the readable side of a TransformStream
94+export const _transformReadable = (ts) => {
95+ return ts.readable;
96+};
97+
98+// Get the writable side of a TransformStream
99+export const _transformWritable = (ts) => {
100+ return ts.writable;
101+};
102+
103+// Batch-read up to N elements from a Reader in one Aff call.
104+// Returns { values: Array, done: Boolean }
105+// Cancellation: sets a flag that stops the pump loop and suppresses callbacks.
106+export const _readBatchImpl = (reader) => (batchSize) => (onError, onSuccess) => {
107+ let cancelled = false;
108+ const values = [];
109+ function pump() {
110+ if (cancelled) return;
111+ if (values.length >= batchSize) {
112+ onSuccess({ values, done: false });
113+ return;
114+ }
115+ reader.read().then(
116+ ({ done, value }) => {
117+ if (cancelled) return;
118+ if (done) {
119+ onSuccess({ values, done: true });
120+ } else {
121+ values.push(value);
122+ pump();
123+ }
124+ },
125+ (err) => { if (!cancelled) onError(err); }
126+ );
127+ }
128+ pump();
129+ return (cancelError, onCancelerError, onCancelerSuccess) => {
130+ cancelled = true;
131+ onCancelerSuccess();
132+ };
133+};
134+
135+// Extract the values array from a batch result
136+export const _batchResultValues = (result) => {
137+ return result.values;
138+};
139+
140+// Check if a batch result indicates stream end
141+export const _batchResultDone = (result) => {
142+ return result.done;
143+};
144+
145+// Cancel a reader and release the lock (async, safe even with pending reads).
146+// reader.cancel() resolves pending reads, then releaseLock() drops the lock.
147+export const _cancelReaderImpl = (reader) => (onError, onSuccess) => {
148+ reader.cancel().then(
149+ () => { reader.releaseLock(); onSuccess(); },
150+ (err) => onError(err)
151+ );
152+ return (cancelError, onCancelerError, onCancelerSuccess) => { onCancelerSuccess(); };
153+};
154+
155+// Convert an Om Variant error to a JS Error, preserving info.
156+// Variant internal shape: { type: string, value: ... }
157+// If value is already an Error (exception tag), pass it through.
158+// Otherwise create an Error with descriptive message and attach original variant.
159+export const _variantToError = (variant) => {
160+ if (variant && typeof variant.type === "string") {
161+ if (variant.type === "exception" && variant.value instanceof Error) {
162+ return variant.value;
163+ }
164+ const tag = variant.type;
165+ let msg;
166+ try {
167+ msg = "Stream error [" + tag + "]: " + JSON.stringify(variant.value);
168+ } catch (_e) {
169+ msg = "Stream error [" + tag + "]";
170+ }
171+ const err = new Error(msg);
172+ err.originalVariant = variant;
173+ return err;
174+ }
175+ return new Error("Stream error: " + String(variant));
176+};
src/Yoga/Om/Strom/WebStream.pursnew file+360
@@ -0,0 +1,360 @@
1+module Yoga.Om.Strom.WebStream
2+ ( ReadableStream
3+ , WritableStream
4+ , TransformStream
5+ , toReadableStream
6+ , fromReadableStream
7+ , fromReadableStreamWith
8+ , useReadableStream
9+ , useReadableStreamWith
10+ , runWritable
11+ , pipeThrough
12+ ) where
13+
14+import Prelude
15+
16+import Control.Monad.Error.Class (throwError)
17+import Control.Monad.Rec.Class (Step(..))
18+import Data.Array as Array
19+import Data.Either (Either(..))
20+import Data.Maybe (Maybe(..), maybe)
21+import Data.Traversable (traverse_)
22+import Data.Tuple (Tuple(..))
23+import Data.Variant (Variant)
24+import Effect (Effect)
25+import Effect.Aff (Aff, Fiber, bracket, killFiber, launchAff, try)
26+import Effect.Aff.Class (liftAff)
27+import Effect.Aff.Compat (EffectFnAff, fromEffectFnAff)
28+import Effect.Class (liftEffect)
29+import Effect.Exception as Exception
30+import Effect.Ref as Ref
31+import Yoga.Om (Om)
32+import Yoga.Om as Om
33+import Yoga.Om.Strom (Strom, mkStrom, runStrom)
34+
35+--------------------------------------------------------------------------------
36+-- Foreign Types
37+--------------------------------------------------------------------------------
38+
39+foreign import data ReadableStream :: Type -> Type
40+foreign import data WritableStream :: Type -> Type
41+foreign import data TransformStream :: Type -> Type -> Type
42+
43+-- Internal opaque types for FFI
44+foreign import data Controller :: Type -> Type
45+foreign import data Reader :: Type -> Type
46+foreign import data Writer :: Type -> Type
47+foreign import data Promise :: Type -> Type
48+foreign import data BatchResult :: Type -> Type
49+
50+--------------------------------------------------------------------------------
51+-- Foreign Imports
52+--------------------------------------------------------------------------------
53+
54+-- ReadableStream construction
55+foreign import _newReadableStream
56+ :: forall a
57+ . (Controller a -> Effect (Promise Unit))
58+ -> Effect Unit
59+ -> Effect (ReadableStream a)
60+
61+foreign import _enqueue :: forall a. Controller a -> a -> Effect Unit
62+foreign import _closeController :: forall a. Controller a -> Effect Unit
63+foreign import _errorController :: forall a. Controller a -> Exception.Error -> Effect Unit
64+
65+-- Promise construction from callbacks
66+foreign import _makePromise
67+ :: forall a
68+ . ((a -> Effect Unit) -> (Exception.Error -> Effect Unit) -> Effect Unit)
69+ -> Effect (Promise a)
70+
71+-- Reader operations
72+foreign import _getReader :: forall a. ReadableStream a -> Effect (Reader a)
73+foreign import _releaseReader :: forall a. Reader a -> Effect Unit
74+foreign import _cancelReaderImpl :: forall a. Reader a -> EffectFnAff Unit
75+
76+-- Batch reader operations
77+foreign import _readBatchImpl :: forall a. Reader a -> Int -> EffectFnAff (BatchResult a)
78+foreign import _batchResultValues :: forall a. BatchResult a -> Array a
79+foreign import _batchResultDone :: forall a. BatchResult a -> Boolean
80+
81+-- Error conversion
82+foreign import _variantToError :: forall err. Variant err -> Exception.Error
83+
84+-- Writer operations
85+foreign import _getWriter :: forall a. WritableStream a -> Effect (Writer a)
86+foreign import _writeWriterImpl :: forall a. Writer a -> a -> EffectFnAff Unit
87+foreign import _closeWriterImpl :: forall a. Writer a -> EffectFnAff Unit
88+foreign import _writerReadyImpl :: forall a. Writer a -> EffectFnAff Unit
89+
90+-- TransformStream
91+foreign import _newTransformStream :: forall a b. Effect (TransformStream a b)
92+foreign import _transformReadable :: forall a b. TransformStream a b -> ReadableStream b
93+foreign import _transformWritable :: forall a b. TransformStream a b -> WritableStream a
94+
95+--------------------------------------------------------------------------------
96+-- Internal helpers
97+--------------------------------------------------------------------------------
98+
99+readBatch :: forall a. Reader a -> Int -> Aff (BatchResult a)
100+readBatch reader batchSize = fromEffectFnAff (_readBatchImpl reader batchSize)
101+
102+writeWriter :: forall a. Writer a -> a -> Aff Unit
103+writeWriter writer value = fromEffectFnAff (_writeWriterImpl writer value)
104+
105+closeWriter :: forall a. Writer a -> Aff Unit
106+closeWriter writer = fromEffectFnAff (_closeWriterImpl writer)
107+
108+writerReady :: forall a. Writer a -> Aff Unit
109+writerReady writer = fromEffectFnAff (_writerReadyImpl writer)
110+
111+--------------------------------------------------------------------------------
112+-- Strom → ReadableStream (shared core)
113+--------------------------------------------------------------------------------
114+
115+-- | Internal: builds a ReadableStream from a Strom with a cleanup action
116+-- | that runs on close, error, or cancel. The cleanup action must be
117+-- | idempotent (it may be called more than once).
118+stromToReadableStream :: forall a ctx err. Effect Unit -> ctx -> Strom ctx err a -> Effect (ReadableStream a)
119+stromToReadableStream cleanup ctx strom = do
120+ stromRef <- Ref.new strom
121+ fiberRef <- Ref.new (Nothing :: Maybe (Fiber Unit))
122+ cancelledRef <- Ref.new false
123+ let
124+ -- Keep pulling until we enqueue data or the stream ends.
125+ -- ReadableStream does NOT re-call pull when nothing is enqueued,
126+ -- so we must loop internally for empty chunks (e.g. from filterStrom).
127+ pullLoop :: Controller a -> Aff Unit
128+ pullLoop controller = do
129+ cancelled <- liftEffect $ Ref.read cancelledRef
130+ when (not cancelled) do
131+ currentStrom <- liftEffect $ Ref.read stromRef
132+ result <- Om.runReader ctx (runStrom currentStrom)
133+ case result of
134+ Left omErr -> liftEffect do
135+ cleanup
136+ _errorController controller (_variantToError omErr)
137+ Right step -> case step of
138+ Done Nothing -> liftEffect do
139+ cleanup
140+ _closeController controller
141+ Done (Just chunk) -> liftEffect do
142+ traverse_ (_enqueue controller) chunk
143+ cleanup
144+ _closeController controller
145+ Loop (Tuple maybeChunk next) -> do
146+ liftEffect $ Ref.write next stromRef
147+ case maybeChunk of
148+ Nothing -> pullLoop controller
149+ Just chunk -> liftEffect $ traverse_ (_enqueue controller) chunk
150+
151+ pull :: Controller a -> Effect (Promise Unit)
152+ pull controller =
153+ _makePromise \resolve _reject -> do
154+ fiber <- launchAff do
155+ result <- try (pullLoop controller)
156+ liftEffect do
157+ case result of
158+ -- pullLoop already signalled the controller (error/close) before
159+ -- any Aff-level throw, or the controller is already in a terminal
160+ -- state. Avoid calling _errorController again — it would throw on
161+ -- an already-errored/closed controller and the Promise would hang.
162+ Left _err -> cleanup
163+ Right _ -> pure unit
164+ resolve unit
165+ Ref.write (Just fiber) fiberRef
166+
167+ cancel :: Effect Unit
168+ cancel = do
169+ Ref.write true cancelledRef
170+ -- Kill the fiber first so the JS cancelled flag in _readBatchImpl is set
171+ -- before cleanup calls releaseLock (which rejects pending reads).
172+ maybeFiber <- Ref.read fiberRef
173+ case maybeFiber of
174+ Nothing -> pure unit
175+ Just fiber ->
176+ void $ launchAff $ killFiber (Exception.error "cancelled") fiber
177+ _currentStrom <- Ref.read stromRef
178+ pure unit -- TODO: cancelStrom currentStrom
179+ cleanup
180+
181+ _newReadableStream pull cancel
182+
183+--------------------------------------------------------------------------------
184+-- Strom → ReadableStream (public)
185+--------------------------------------------------------------------------------
186+
187+-- | Convert a Strom to a ReadableStream. Elements are enqueued individually.
188+-- | Requires ctx to run the Om effects. Errors become stream errors with
189+-- | preserved error information. Supports cancellation — when the consumer
190+-- | cancels the ReadableStream, the internal fiber is killed.
191+toReadableStream :: forall a ctx err. ctx -> Strom ctx err a -> Effect (ReadableStream a)
192+toReadableStream = stromToReadableStream (pure unit)
193+
194+--------------------------------------------------------------------------------
195+-- ReadableStream → Strom
196+--------------------------------------------------------------------------------
197+
198+-- | Default batch size for `fromReadableStream` (matches rangeStrom's chunk size).
199+defaultChunkSize :: Int
200+defaultChunkSize = 1000
201+
202+-- | Convert a ReadableStream to a Strom using batched reads.
203+-- | Each pull reads up to 1000 elements from the underlying reader in a tight
204+-- | JS loop, producing properly-sized chunks that match Strom's batch model.
205+-- | The reader is released when the stream ends.
206+-- |
207+-- | Note: if the consumer stops pulling early (e.g. via `takeStrom`), the
208+-- | reader lock is not released until GC. Use `useReadableStream` for
209+-- | guaranteed cleanup on abandonment.
210+fromReadableStream :: forall a ctx err. ReadableStream a -> Strom ctx err a
211+fromReadableStream = fromReadableStreamWith { chunkSize: defaultChunkSize }
212+
213+-- | Convert a ReadableStream to a Strom with a configurable chunk (batch) size.
214+-- | Larger values reduce per-chunk overhead; smaller values reduce latency.
215+fromReadableStreamWith :: forall a ctx err. { chunkSize :: Int } -> ReadableStream a -> Strom ctx err a
216+fromReadableStreamWith { chunkSize } stream = mkStrom do
217+ reader <- liftEffect $ _getReader stream
218+ runStrom (readerToStrom chunkSize reader)
219+
220+-- | Internal: pull from a Reader in batches, releasing the reader on completion.
221+readerToStrom :: forall a ctx err. Int -> Reader a -> Strom ctx err a
222+readerToStrom batchSize reader =
223+ readerToStromWithCleanupAndSize batchSize (_releaseReader reader) reader
224+
225+--------------------------------------------------------------------------------
226+-- Strom → WritableStream sink
227+--------------------------------------------------------------------------------
228+
229+-- | Drain a Strom into a WritableStream. Resolves when the stream is fully written.
230+-- | Backpressure comes from the writer's ready promise.
231+runWritable :: forall a ctx err. WritableStream a -> Strom ctx err a -> Om ctx err Unit
232+runWritable writable strom = do
233+ writer <- liftEffect $ _getWriter writable
234+ Om.handleErrors'
235+ (\err -> do
236+ -- Try to close on error; swallow secondary failures
237+ _ <- liftAff $ try $ closeWriter writer
238+ throwError err
239+ )
240+ do
241+ go writer strom
242+ liftAff $ closeWriter writer
243+ where
244+ go :: Writer a -> Strom ctx err a -> Om ctx err Unit
245+ go writer s = do
246+ step <- runStrom s
247+ case step of
248+ Done Nothing -> pure unit
249+ Done (Just chunk) -> writeChunk writer chunk
250+ Loop (Tuple maybeChunk next) -> do
251+ maybe (pure unit) (writeChunk writer) maybeChunk
252+ go writer next
253+
254+ writeChunk :: Writer a -> Array a -> Om ctx err Unit
255+ writeChunk writer chunk = traverse_ (writeElement writer) chunk
256+
257+ writeElement :: Writer a -> a -> Om ctx err Unit
258+ writeElement writer value = liftAff do
259+ writerReady writer
260+ writeWriter writer value
261+
262+--------------------------------------------------------------------------------
263+-- Piping utilities
264+--------------------------------------------------------------------------------
265+
266+-- | Pipe a ReadableStream through a Strom transformation, producing a new
267+-- | ReadableStream. This is the primary integration point: take web streams,
268+-- | transform them with Strom combinators, and produce a web stream.
269+-- |
270+-- | Reader cleanup: the reader acquired from the source ReadableStream is
271+-- | released when the transformed stream ends naturally or when the output
272+-- | ReadableStream is cancelled by the consumer.
273+pipeThrough
274+ :: forall a b ctx err
275+ . ctx
276+ -> (Strom ctx err a -> Strom ctx err b)
277+ -> ReadableStream a
278+ -> Effect (ReadableStream b)
279+pipeThrough ctx transform readable = do
280+ reader <- _getReader readable
281+ -- Idempotent release — safe to call multiple times
282+ releasedRef <- Ref.new false
283+ let
284+ releaseOnce :: Effect Unit
285+ releaseOnce = do
286+ alreadyReleased <- Ref.read releasedRef
287+ when (not alreadyReleased) do
288+ Ref.write true releasedRef
289+ _releaseReader reader
290+
291+ inputStrom = readerToStromWithCleanupAndSize defaultChunkSize releaseOnce reader
292+
293+ stromToReadableStream releaseOnce ctx (transform inputStrom)
294+
295+-- | Internal: pull from a Reader in batches with configurable chunk size,
296+-- | calling a cleanup action on stream completion.
297+readerToStromWithCleanupAndSize :: forall a ctx err. Int -> Effect Unit -> Reader a -> Strom ctx err a
298+readerToStromWithCleanupAndSize batchSize _cleanup reader = go -- TODO: addCancel cleanup go
299+ where
300+ go :: Strom ctx err a
301+ go = mkStrom do
302+ result <- liftAff $ readBatch reader batchSize
303+ let values = _batchResultValues result
304+ let done = _batchResultDone result
305+ if done then
306+ if Array.null values then pure $ Done Nothing
307+ else pure $ Done $ Just values
308+ else
309+ pure $ Loop $ Tuple (Just values) go
310+
311+--------------------------------------------------------------------------------
312+-- Bracketed ReadableStream consumption
313+--------------------------------------------------------------------------------
314+
315+-- | Consume a ReadableStream with guaranteed reader cleanup via `Aff.bracket`.
316+-- | The reader is released when the callback completes, errors, or the fiber
317+-- | is cancelled — unlike `fromReadableStream` which leaks the reader on
318+-- | early abandonment.
319+-- |
320+-- | ```purescript
321+-- | useReadableStream stream \strom -> do
322+-- | Strom.runCollect (Strom.takeStrom 5 strom)
323+-- | ```
324+useReadableStream
325+ :: forall a b ctx err
326+ . ReadableStream a
327+ -> (Strom ctx err a -> Om ctx err b)
328+ -> Om ctx err b
329+useReadableStream = useReadableStreamWith { chunkSize: defaultChunkSize }
330+
331+-- | Like `useReadableStream` but with a configurable chunk (batch) size.
332+useReadableStreamWith
333+ :: forall a b ctx err
334+ . { chunkSize :: Int }
335+ -> ReadableStream a
336+ -> (Strom ctx err a -> Om ctx err b)
337+ -> Om ctx err b
338+useReadableStreamWith { chunkSize } stream callback = do
339+ ctx <- Om.ask
340+ result <- liftAff $ bracket
341+ (liftEffect do
342+ reader <- _getReader stream
343+ releasedRef <- Ref.new false
344+ pure { reader, releasedRef })
345+ (\{ reader, releasedRef } -> do
346+ alreadyReleased <- liftEffect $ Ref.read releasedRef
347+ when (not alreadyReleased) do
348+ liftEffect $ Ref.write true releasedRef
349+ fromEffectFnAff (_cancelReaderImpl reader))
350+ (\{ reader, releasedRef } -> do
351+ let releaseOnce = do
352+ alreadyReleased <- Ref.read releasedRef
353+ when (not alreadyReleased) do
354+ Ref.write true releasedRef
355+ _releaseReader reader
356+ inputStrom = readerToStromWithCleanupAndSize chunkSize releaseOnce reader
357+ Om.runReader ctx (callback inputStrom))
358+ case result of
359+ Left err -> throwError err
360+ Right value -> pure value
test/Test/Main.pursnew file+12
@@ -0,0 +1,12 @@
1+module Test.Main.Strom where
2+
3+import Prelude
4+
5+import Effect (Effect)
6+import Test.Spec.Reporter (consoleReporter)
7+import Test.Spec.Runner.Node (runSpecAndExitProcess)
8+import Yoga.Om.Strom.Test as StromSpec
9+
10+main :: Effect Unit
11+main = runSpecAndExitProcess [consoleReporter] do
12+ StromSpec.spec
test/Test/Strom/Spec.purs+3−4
@@ -6,13 +6,12 @@ import Control.Alt ((<|>))
66import Data.Array as Array
77import Data.Maybe (Maybe(..))
88import Data.Tuple (Tuple(..))
9−import Data.Time.Duration (Milliseconds(..))
109import Effect.Aff (Aff)
1110import Effect.Ref as Ref
1211import Effect.Class (liftEffect)
13−import Effect.Exception (throwException, error)
12+import Effect.Exception (throwException)
1413import Test.Spec (Spec, describe, it)
15−import Test.Spec.Assertions (shouldEqual, fail)
14+import Test.Spec.Assertions (shouldEqual)
1615import Yoga.Om (Om)
1716import Yoga.Om as Om
1817import Yoga.Om.Strom as Strom
@@ -156,7 +155,7 @@ spec = do
156155 result `shouldEqual` [ "1a", "2b", "3c" ]
157156
158157 it "tap observes without modifying" do
159− sideEffectRef <- liftEffect $ Ref.new 0
158+ _sideEffectRef <- liftEffect $ Ref.new 0
160159 result <- runOm $
161160 Strom.fromArray [ 1, 2, 3 ]
162161 # Strom.tapStrom (\n -> let _ = n in unit) -- Pure observation
test/Test/WebStream.jsnew file+106
@@ -0,0 +1,106 @@
1+// Test FFI helpers for WebStream tests
2+
3+// Collect all values from a ReadableStream into an array — returns Aff
4+export const _collectReadableStream = (stream) => (onError, onSuccess) => {
5+ const reader = stream.getReader();
6+ const values = [];
7+ function pump() {
8+ reader.read().then(
9+ ({ done, value }) => {
10+ if (done) {
11+ onSuccess(values);
12+ } else {
13+ values.push(value);
14+ pump();
15+ }
16+ },
17+ (err) => onError(err)
18+ );
19+ }
20+ pump();
21+ return (cancelError, onCancelerError, onCancelerSuccess) => {
22+ reader.releaseLock();
23+ onCancelerSuccess();
24+ };
25+};
26+
27+// Create a WritableStream that collects values into an array
28+export const _createWritableStreamCollector = () => {
29+ const values = [];
30+ const stream = new WritableStream({
31+ write(chunk) {
32+ values.push(chunk);
33+ }
34+ });
35+ return {
36+ stream: stream,
37+ getValues: () => values.slice()
38+ };
39+};
40+
41+// Create a ReadableStream that produces incrementing integers.
42+// Each read() produces one value after a 1ms delay.
43+// Returns { stream, getProducedCount } — count grows per read,
44+// so after pump stops, the count stabilises.
45+export const _createCountingStream = () => {
46+ let count = 0;
47+ let cancelled = false;
48+
49+ const stream = new ReadableStream({
50+ pull(controller) {
51+ if (cancelled) return;
52+ return new Promise(resolve => {
53+ setTimeout(() => {
54+ if (!cancelled) {
55+ count++;
56+ controller.enqueue(count);
57+ }
58+ resolve();
59+ }, 1);
60+ });
61+ },
62+ cancel() {
63+ cancelled = true;
64+ }
65+ });
66+
67+ return {
68+ stream,
69+ getProducedCount: () => count
70+ };
71+};
72+
73+// Check if a ReadableStream is locked (has an active reader)
74+export const _isStreamLocked = (stream) => () => {
75+ return stream.locked;
76+};
77+
78+// Read N elements from a ReadableStream then cancel — returns EffectFnAff
79+export const _readNThenCancel = (stream) => (n) => (onError, onSuccess) => {
80+ const reader = stream.getReader();
81+ const values = [];
82+ function pump() {
83+ if (values.length >= n) {
84+ reader.cancel("read enough").then(
85+ () => { reader.releaseLock(); onSuccess(values); },
86+ (err) => onError(err)
87+ );
88+ return;
89+ }
90+ reader.read().then(
91+ ({ done, value }) => {
92+ if (done) {
93+ onSuccess(values);
94+ } else {
95+ values.push(value);
96+ pump();
97+ }
98+ },
99+ (err) => onError(err)
100+ );
101+ }
102+ pump();
103+ return (cancelError, onCancelerError, onCancelerSuccess) => {
104+ onCancelerSuccess();
105+ };
106+};
test/Test/WebStream.pursnew file+223
@@ -0,0 +1,223 @@
1+module Test.WebStream where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Effect (Effect)
7+import Effect.Aff (Aff, delay, forkAff, killFiber)
8+import Effect.Aff.Compat (EffectFnAff, fromEffectFnAff)
9+import Effect.Class (liftEffect)
10+import Effect.Exception as Exception
11+import Effect.Ref as Ref
12+import Test.Spec (Spec, describe, it)
13+import Test.Spec.Assertions (shouldEqual, shouldSatisfy)
14+import Yoga.Om (Om)
15+import Yoga.Om as Om
16+import Yoga.Om.Strom as Strom
17+import Yoga.Om.Strom.WebStream as WS
18+import Data.Time.Duration (Milliseconds(..))
19+
20+-- Helper to run Om in tests (same as in Strom tests)
21+runOm :: forall a. Om {} () a -> Aff a
22+runOm om = Om.runOm {} { exception: \err -> liftEffect (Exception.throwException err) } om
23+
24+-- FFI helpers for testing
25+foreign import _collectReadableStream :: forall a. WS.ReadableStream a -> EffectFnAff (Array a)
26+foreign import _createWritableStreamCollector :: forall a. Effect { stream :: WS.WritableStream a, getValues :: Effect (Array a) }
27+foreign import _readNThenCancel :: forall a. WS.ReadableStream a -> Int -> EffectFnAff (Array a)
28+foreign import _createCountingStream :: Effect { stream :: WS.ReadableStream Int, getProducedCount :: Effect Int }
29+foreign import _isStreamLocked :: forall a. WS.ReadableStream a -> Effect Boolean
30+
31+collectReadableStream :: forall a. WS.ReadableStream a -> Aff (Array a)
32+collectReadableStream = fromEffectFnAff <<< _collectReadableStream
33+
34+readNThenCancel :: forall a. WS.ReadableStream a -> Int -> Aff (Array a)
35+readNThenCancel stream n = fromEffectFnAff (_readNThenCancel stream n)
36+
37+spec :: Spec Unit
38+spec = do
39+ describe "WebStream" do
40+
41+ describe "toReadableStream" do
42+
43+ it "converts empty Strom to empty ReadableStream" do
44+ rs <- liftEffect $ WS.toReadableStream {} (Strom.empty :: Strom.Strom {} () Int)
45+ result <- collectReadableStream rs
46+ result `shouldEqual` ([] :: Array Int)
47+
48+ it "converts single-element Strom to ReadableStream" do
49+ rs <- liftEffect $ WS.toReadableStream {} (Strom.succeed 42)
50+ result <- collectReadableStream rs
51+ result `shouldEqual` [ 42 ]
52+
53+ it "converts multi-element Strom to ReadableStream" do
54+ rs <- liftEffect $ WS.toReadableStream {} (Strom.fromArray [ 1, 2, 3, 4, 5 ])
55+ result <- collectReadableStream rs
56+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
57+
58+ it "converts range Strom to ReadableStream" do
59+ rs <- liftEffect $ WS.toReadableStream {} (Strom.rangeStrom 1 6)
60+ result <- collectReadableStream rs
61+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
62+
63+ it "converts transformed Strom to ReadableStream" do
64+ let strom = Strom.fromArray [ 1, 2, 3 ] # Strom.mapStrom (_ * 10)
65+ rs <- liftEffect $ WS.toReadableStream {} strom
66+ result <- collectReadableStream rs
67+ result `shouldEqual` [ 10, 20, 30 ]
68+
69+ describe "fromReadableStream" do
70+
71+ it "converts ReadableStream back to Strom" do
72+ rs <- liftEffect $ WS.toReadableStream {} (Strom.fromArray [ 10, 20, 30 ])
73+ let strom = WS.fromReadableStream rs :: Strom.Strom {} () Int
74+ result <- runOm $ Strom.runCollect strom
75+ result `shouldEqual` [ 10, 20, 30 ]
76+
77+ it "converts empty ReadableStream to empty Strom" do
78+ rs <- liftEffect $ WS.toReadableStream {} (Strom.empty :: Strom.Strom {} () Int)
79+ let strom = WS.fromReadableStream rs :: Strom.Strom {} () Int
80+ result <- runOm $ Strom.runCollect strom
81+ result `shouldEqual` ([] :: Array Int)
82+
83+ describe "fromReadableStreamWith" do
84+
85+ it "allows custom chunk size" do
86+ rs <- liftEffect $ WS.toReadableStream {} (Strom.fromArray [ 1, 2, 3, 4, 5 ])
87+ let strom = WS.fromReadableStreamWith { chunkSize: 2 } rs :: Strom.Strom {} () Int
88+ result <- runOm $ Strom.runCollect strom
89+ result `shouldEqual` [ 1, 2, 3, 4, 5 ]
90+
91+ describe "roundtrip" do
92+
93+ it "Strom → ReadableStream → Strom preserves elements" do
94+ let original = [ 1, 2, 3, 4, 5 ]
95+ rs <- liftEffect $ WS.toReadableStream {} (Strom.fromArray original)
96+ let strom = WS.fromReadableStream rs :: Strom.Strom {} () Int
97+ result <- runOm $ Strom.runCollect strom
98+ result `shouldEqual` original
99+
100+ it "roundtrip with transformations" do
101+ let original = Strom.fromArray [ 1, 2, 3 ] # Strom.filterStrom (_ > 1) # Strom.mapStrom (_ * 2)
102+ rs <- liftEffect $ WS.toReadableStream {} original
103+ let strom = WS.fromReadableStream rs :: Strom.Strom {} () Int
104+ result <- runOm $ Strom.runCollect strom
105+ result `shouldEqual` [ 4, 6 ]
106+
107+ it "large roundtrip preserves all 10000 elements" do
108+ let original = Array.range 1 10000
109+ rs <- liftEffect $ WS.toReadableStream {} (Strom.rangeStrom 1 10001)
110+ let strom = WS.fromReadableStream rs :: Strom.Strom {} () Int
111+ result <- runOm $ Strom.runCollect strom
112+ result `shouldEqual` original
113+
114+ describe "cancellation" do
115+
116+ it "cancelling ReadableStream does not hang" do
117+ rs <- liftEffect $ WS.toReadableStream {} (Strom.rangeStrom 1 1000000)
118+ -- Read 5 elements then cancel
119+ result <- readNThenCancel rs 5
120+ Array.length result `shouldEqual` 5
121+
122+ describe "runWritable" do
123+
124+ it "writes Strom elements to WritableStream" do
125+ { stream: ws, getValues } <- liftEffect _createWritableStreamCollector
126+ runOm $ WS.runWritable ws (Strom.fromArray [ 1, 2, 3 ])
127+ result <- liftEffect getValues
128+ result `shouldEqual` [ 1, 2, 3 ]
129+
130+ it "handles empty Strom to WritableStream" do
131+ { stream: ws, getValues } <- liftEffect (_createWritableStreamCollector :: Effect { stream :: WS.WritableStream Int, getValues :: Effect (Array Int) })
132+ runOm $ WS.runWritable ws (Strom.empty :: Strom.Strom {} () Int)
133+ result <- liftEffect getValues
134+ result `shouldEqual` ([] :: Array Int)
135+
136+ describe "pipeThrough" do
137+
138+ it "transforms ReadableStream through Strom pipeline" do
139+ -- Create a source ReadableStream
140+ source <- liftEffect $ WS.toReadableStream {} (Strom.fromArray [ 1, 2, 3, 4, 5 ])
141+ -- Pipe through a Strom transformation
142+ transformed <- liftEffect $ WS.pipeThrough {} (Strom.filterStrom (_ > 2) >>> Strom.mapStrom (_ * 10)) source
143+ -- Collect the result
144+ result <- collectReadableStream transformed
145+ result `shouldEqual` [ 30, 40, 50 ]
146+
147+ it "pipes through with take" do
148+ source <- liftEffect $ WS.toReadableStream {} (Strom.rangeStrom 1 100)
149+ transformed <- liftEffect $ WS.pipeThrough {} (Strom.takeStrom 3) source
150+ result <- collectReadableStream transformed
151+ result `shouldEqual` [ 1, 2, 3 ]
152+
153+ it "cancelling piped stream releases reader" do
154+ source <- liftEffect $ WS.toReadableStream {} (Strom.rangeStrom 1 1000000)
155+ transformed <- liftEffect $ WS.pipeThrough {} (Strom.mapStrom (_ * 2)) source
156+ -- Read 5 elements then cancel
157+ result <- readNThenCancel transformed 5
158+ Array.length result `shouldEqual` 5
159+
160+ describe "cancellation (batch read)" do
161+
162+ it "killing fiber during batch read stops the pump loop" do
163+ { stream, getProducedCount } <- liftEffect _createCountingStream
164+ -- Start reading in a fiber — chunkSize 10000 means pump loops many times
165+ let strom = WS.fromReadableStreamWith { chunkSize: 10000 } stream :: Strom.Strom {} () Int
166+ fiber <- forkAff $ runOm $ Strom.runCollect strom
167+ -- Let it produce some values (~50 reads at 1ms each)
168+ delay (Milliseconds 50.0)
169+ -- Kill the fiber — the cancelled flag stops the pump from further reads
170+ killFiber (Exception.error "test cancel") fiber
171+ -- Record count shortly after kill (allow one in-flight read to complete)
172+ delay (Milliseconds 10.0)
173+ countAtKill <- liftEffect getProducedCount
174+ -- Wait and check that the producer has stopped (count stabilised)
175+ delay (Milliseconds 100.0)
176+ countLater <- liftEffect getProducedCount
177+ -- With cancellation fix, the pump stops. At most 1 in-flight read
178+ -- may complete after the flag is set.
179+ (countLater - countAtKill) `shouldSatisfy` (_ < 5)
180+
181+ describe "useReadableStream" do
182+
183+ it "releases reader after full consumption" do
184+ rs <- liftEffect $ WS.toReadableStream {} (Strom.fromArray [ 1, 2, 3 ])
185+ result <- runOm $ WS.useReadableStream rs \strom ->
186+ Strom.runCollect strom
187+ result `shouldEqual` [ 1, 2, 3 ]
188+
189+ it "releases reader after partial consumption (takeStrom)" do
190+ { stream, getProducedCount: _ } <- liftEffect _createCountingStream
191+ result <- runOm $ WS.useReadableStreamWith { chunkSize: 10 } stream \strom ->
192+ Strom.runCollect (Strom.takeStrom 5 strom)
193+ Array.length result `shouldEqual` 5
194+ -- Wait for async reader.cancel() to complete
195+ delay (Milliseconds 50.0)
196+ -- Stream should be unlocked after useReadableStream returns
197+ locked <- liftEffect $ _isStreamLocked stream
198+ locked `shouldEqual` false
199+
200+ it "releases reader on fiber cancellation" do
201+ { stream, getProducedCount: _ } <- liftEffect _createCountingStream
202+ -- Fork useReadableStream that reads forever
203+ fiber <- forkAff $ runOm $ WS.useReadableStreamWith { chunkSize: 10 } stream \strom ->
204+ Strom.runCollect strom
205+ -- Let it start reading
206+ delay (Milliseconds 30.0)
207+ -- Kill the fiber — bracket cleanup should cancel the reader
208+ killFiber (Exception.error "test cancel") fiber
209+ -- Wait for async reader.cancel() to complete
210+ delay (Milliseconds 50.0)
211+ locked <- liftEffect $ _isStreamLocked stream
212+ locked `shouldEqual` false
213+
214+ describe "cancelStrom integration" do
215+
216+ it "cancelling ReadableStream releases bracket resources" do
217+ ref <- liftEffect $ Ref.new false
218+ let strom = Strom.bracket (pure unit) (\_ -> liftEffect $ Ref.write true ref) (\_ -> Strom.rangeStrom 1 1000000)
219+ rs <- liftEffect $ WS.toReadableStream {} strom
220+ _ <- readNThenCancel rs 5
221+ delay (Milliseconds 50.0)
222+ released <- liftEffect $ Ref.read ref
223+ released `shouldEqual` true