@panagos/jojoscript 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # JojoScript 0.8.0
1
+ # JojoScript 0.9.0
2
2
 
3
3
  JojoScript is a small JavaScript-compatible language and runtime for
4
4
  expressive, composable pipelines. It adds lightweight syntax for declarations,
@@ -6,7 +6,8 @@ functions, pattern matching, type annotations, and pipelines while preserving
6
6
  ordinary JavaScript. Its compiler uses a lightweight lexer and source
7
7
  transformations rather than a full AST, keeping the toolchain compact. The
8
8
  project also provides lazy and async collection operators, checkpointing,
9
- retries, metrics, pipeline graphing, CLI workflows, and VS Code editor support.
9
+ retries, metrics, pipeline graphing, CLI workflows, effect handlers, and
10
+ VS Code editor support.
10
11
 
11
12
  See [CHANGELOG.md](CHANGELOG.md) for release history and
12
13
  [example/](example/) for a complete runnable application that exercises
@@ -305,7 +306,368 @@ auto-imported):
305
306
  See [ITERATOR_PROPOSAL.md](ITERATOR_PROPOSAL.md) and [ITERATORS.md](ITERATORS.md)
306
307
  for the full laziness/memory model.
307
308
 
308
- ## JavaScript compatibility
309
+ ## Effect Handlers
310
+
311
+ > This is a deliberately simplified effect-handler model, not full
312
+ > algebraic effects with delimited continuations (no `resume`, no
313
+ > resuming a suspended computation zero/more-than-once, no re-throwing to
314
+ > an outer handler after partial handling). What it *does* give you: named
315
+ > effects, dynamic handler dispatch, nesting/overriding, deterministic
316
+ > mock handlers, record/replay, and a `parallel()` effect — all with plain
317
+ > JavaScript interop. If you already know algebraic effects from OCaml/Eff/
318
+ > Koka, think "a practical subset", not "the whole theory".
319
+
320
+ The central idea:
321
+
322
+ ```
323
+ Pipeline
324
+ describes computation
325
+
326
+ Execution Plan
327
+ describes how the computation can be executed
328
+
329
+ Effect
330
+ describes an external capability/action
331
+
332
+ Effect Handler
333
+ decides how that capability/action is interpreted
334
+ ```
335
+
336
+ **Describe the computation once, choose its interpretation separately.**
337
+
338
+ ### What an effect is
339
+
340
+ `effect name(params)` declares a named capability — "this program wants to
341
+ fetch a user", not "this program makes an HTTP GET request". It compiles to
342
+ a plain runtime call:
343
+
344
+ ```jojo
345
+ effect fetchUser(id)
346
+ effect saveUser(user)
347
+ ```
348
+
349
+ becomes:
350
+
351
+ ```js
352
+ const fetchUser = defineEffect("fetchUser", ["id"])
353
+ const saveUser = defineEffect("saveUser", ["user"])
354
+ ```
355
+
356
+ Calling `fetchUser(id)` doesn't run anything itself — it looks up the
357
+ currently active handler for `"fetchUser"` and delegates to it. **Calling an
358
+ effect always returns a Promise**, even if the active handler is
359
+ synchronous, so the calling code never has to know or care whether the
360
+ handler in charge is a real (often async) implementation or a fake (often
361
+ sync) one.
362
+
363
+ ### What an effect handler is, and how to install one
364
+
365
+ A handler supplies the implementation for one or more named effects, for
366
+ the dynamic extent of a block:
367
+
368
+ ```jojo
369
+ effect fetchUser(id)
370
+ effect saveUser(user)
371
+
372
+ users
373
+ |> map(user => fetchUser(user.id))
374
+ |> map(saveUser)
375
+ ```
376
+
377
+ runs under:
378
+
379
+ ```jojo
380
+ handle {
381
+ fetchUser: id => http.get(`/users/${id}`),
382
+ saveUser: user => database.save(user)
383
+ } {
384
+ users
385
+ |> map(user => fetchUser(user.id))
386
+ |> map(saveUser)
387
+ }
388
+ ```
389
+
390
+ which compiles to:
391
+
392
+ ```js
393
+ (await withHandlers({
394
+ fetchUser: id => http.get(`/users/${id}`),
395
+ saveUser: user => database.save(user)
396
+ }, async () => {
397
+ users
398
+ |> map(user => fetchUser(user.id))
399
+ |> map(saveUser)
400
+ }))
401
+ ```
402
+
403
+ A handler value can be a plain function, or a `{ handle, deterministic }`
404
+ descriptor when you want to declare it explicitly deterministic (useful for
405
+ inspection — see below):
406
+
407
+ ```jojo
408
+ handle {
409
+ fetchUser: { handle: id => fakeUser(id), deterministic: true }
410
+ } {
411
+ ...
412
+ }
413
+ ```
414
+
415
+ Because `handle { ... } { ... }` compiles to `await withHandlers(...)`, it
416
+ must be used inside an `async fn`/`async function`, or at the top level of
417
+ an ES module (Node's top-level `await`) — the same requirement the existing
418
+ `|> await` pipeline stage already has.
419
+
420
+ ### How the same program runs under different handlers
421
+
422
+ The program never mentions which handler is active — only the caller
423
+ decides that, by choosing which `handle { ... }` wraps it:
424
+
425
+ ```jojo
426
+ effect fetchUser(id)
427
+ effect saveUser(user)
428
+
429
+ async fn syncUser(id) {
430
+ user := await fetchUser(id)
431
+ return await saveUser(user)
432
+ }
433
+
434
+ async fn syncUserInProduction(id) {
435
+ return handle {
436
+ fetchUser: id => http.get(`/users/${id}`),
437
+ saveUser: user => database.save(user)
438
+ } {
439
+ return await syncUser(id)
440
+ }
441
+ }
442
+
443
+ async fn syncUserInTests(id) {
444
+ return handle {
445
+ fetchUser: id => ({ id, name: "Fake User" }),
446
+ saveUser: user => ({ ...user, savedTo: "in-memory" })
447
+ } {
448
+ return await syncUser(id)
449
+ }
450
+ }
451
+ ```
452
+
453
+ `syncUser` is identical in both cases; only the handler map differs. See
454
+ [example/src/effects/multiple-handlers.jojo](example/src/effects/multiple-handlers.jojo)
455
+ and
456
+ [example/src/effects/deterministic-testing.jojo](example/src/effects/deterministic-testing.jojo).
457
+
458
+ ### Nested handlers and resolution rules
459
+
460
+ `handle` blocks nest. An inner handler for a given effect name **overrides**
461
+ an outer one; an inner handler map that doesn't mention an effect leaves it
462
+ falling through to the next outer handler that does:
463
+
464
+ ```jojo
465
+ handle {
466
+ fetchUser: outerFake
467
+ } {
468
+ handle {
469
+ fetchUser: innerFake
470
+ } {
471
+ users |> map(fetchUser) // resolves to innerFake
472
+ }
473
+
474
+ users |> map(fetchUser) // resolves to outerFake, outside the inner block
475
+ }
476
+ ```
477
+
478
+ Resolution walks from the innermost active `handle` block outward, taking
479
+ the first one that declares the effect. Once a `handle` block's body
480
+ finishes, its handlers are gone — code that runs afterwards (or a lazy
481
+ pipeline consumed afterwards, see the caveat below) no longer sees them.
482
+
483
+ ### Error handling
484
+
485
+ | Situation | Result |
486
+ |---|---|
487
+ | Effect invoked with no active handler for it | `UnhandledEffectError`: `` Unhandled effect: fetchUser\nNo handler registered for effect "fetchUser" `` |
488
+ | Handler throws (or its returned Promise rejects) | `EffectHandlerError`, naming the effect and wrapping the original error as `.cause` |
489
+ | Handler value isn't a function or `{ handle, deterministic }` | `TypeError`, thrown immediately when `handle { ... }` installs it, not at call time |
490
+ | A handler recursively invokes the same effect (directly, or indirectly through another effect) | `RecursiveEffectError`, naming the effect |
491
+ | Effect invoked entirely outside any `handle` block | Same as "no active handler" — `UnhandledEffectError` |
492
+
493
+ ### `parallel()` as an effect
494
+
495
+ `items |> parallel(n) |> map(fn)` is effect-backed. With no handler
496
+ installed, it behaves exactly as before (a bounded-concurrency worker
497
+ pool — existing programs are unaffected). Installing a `PARALLEL` handler
498
+ lets a different interpretation take over completely:
499
+
500
+ ```jojo
501
+ handle {
502
+ PARALLEL: async ({ items, fn, concurrency }) => {
503
+ // deterministic, strictly sequential — great for tests
504
+ results := []
505
+ for await (const item of items) results.push(await fn(item))
506
+ return results
507
+ }
508
+ } {
509
+ numbers |> parallel(8) |> map(process) |> toArrayAsync |> await
510
+ }
511
+ ```
512
+
513
+ A `PARALLEL` handler receives `{ items, fn, concurrency, meta }` and returns
514
+ an (async) iterable of results in order — the same contract the built-in
515
+ implementation follows. This lets production code use real concurrency,
516
+ tests force deterministic sequential execution, and a debugging handler
517
+ record scheduling information, all without changing the pipeline. See
518
+ [example/src/effects/parallel-effect.jojo](example/src/effects/parallel-effect.jojo).
519
+
520
+ ### Effects in the execution plan
521
+
522
+ `jojo graph`/`analyzePipelines` expose which pipeline nodes reference which
523
+ declared effects, and a best-effort static "is a handler for it declared
524
+ anywhere in this file?" signal:
525
+
526
+ ```jojo
527
+ effect fetchUser(id)
528
+ effect saveUser(user)
529
+
530
+ handle {
531
+ fetchUser: id => ({ id })
532
+ } {
533
+ users
534
+ |> map(user => fetchUser(user.id))
535
+ |> parallel(8)
536
+ |> map(saveUser)
537
+ }
538
+ ```
539
+
540
+ ```
541
+ SOURCE users
542
+ │
543
+ ▼
544
+ MAP user => fetchUser(user.id) [effects: fetchUser]
545
+ │
546
+ ▼
547
+ PARALLEL 8
548
+ │
549
+ ▼
550
+ MAP saveUser [effects: saveUser]
551
+ │
552
+ ▼
553
+ RESULT
554
+ ```
555
+
556
+ Programmatically, `analyzePipelines(source)` returns each pipeline's graph
557
+ with an `effects` array:
558
+
559
+ ```js
560
+ [
561
+ { name: "fetchUser", nodeId: "node-1", staticallyHandled: true },
562
+ { name: "saveUser", nodeId: "node-3", staticallyHandled: false }
563
+ ]
564
+ ```
565
+
566
+ `staticallyHandled` is a textual, best-effort signal (a `handle { name: ... }`
567
+ naming that effect was found somewhere in the file) — it cannot see handlers
568
+ installed conditionally or dynamically at runtime. For that, call
569
+ `describeHandlers()` from inside a running `handle { ... }` block: it
570
+ reports, per currently-handled effect, whether the installed handler
571
+ declared itself `deterministic`, and the declaring effect's `sideEffecting`/
572
+ `replayable` metadata (from `effect name(params)`'s options, or defaults —
573
+ `sideEffecting: true`, `replayable: false`).
574
+
575
+ ### Effects and checkpoint/resume
576
+
577
+ Consider:
578
+
579
+ ```jojo
580
+ effect fetchUser(id)
581
+
582
+ ids
583
+ |> mapAsync(fetchUser)
584
+ |> checkpoint("users-fetched")
585
+ |> toArrayAsync
586
+ ```
587
+
588
+ `checkpoint(name)` either streams items through once and durably saves them
589
+ all, or — if a checkpoint for `name` already completed — returns the saved
590
+ items **without ever touching the upstream iterable at all**. This gives
591
+ effects placed before a checkpoint a specific, non-magical guarantee:
592
+
593
+ - **Once a checkpoint has completed**, a later run does **not** re-invoke
594
+ the upstream effect(s) — the saved items are restored instead. This is
595
+ restoration from checkpoint state, not effect replay: the checkpoint
596
+ store never sees the individual `fetchUser` calls, only the pipeline
597
+ items that came out the other side.
598
+ - **If a checkpoint never completed** (crash, first run, ...), the upstream
599
+ effect(s) run again from scratch on the next attempt — there is no
600
+ per-item resume within an incomplete checkpoint.
601
+
602
+ **This is explicitly not exactly-once effect execution.** It is
603
+ "at-least-once until the checkpoint fully completes, then exactly zero
604
+ additional times after." A non-idempotent, non-replayable effect
605
+ (`replayable: false`, the default) must tolerate being invoked again on a
606
+ crash-and-retry before its checkpoint completes. Mark an effect
607
+ `{ replayable: true }` only if calling it again with the same arguments is
608
+ actually safe. See
609
+ [example/src/effects/checkpoint-resume.jojo](example/src/effects/checkpoint-resume.jojo)
610
+ and `test/effects-checkpoint.test.js` for both cases exercised end to end.
611
+
612
+ ### Record/replay
613
+
614
+ A handler map can be wrapped to record every call (arguments + result) to
615
+ an `EffectLog`, and later replayed from that log without running any real
616
+ code:
617
+
618
+ ```js
619
+ import { recordHandlers, replayHandlers, withHandlers } from "@panagos/jojoscript/runtime"
620
+ import { FileEffectLog } from "@panagos/jojoscript/runtime"
621
+
622
+ const log = new FileEffectLog("./fetchUser.log.json")
623
+
624
+ // Recording run: calls the real handler, and appends each call to the log.
625
+ await withHandlers(recordHandlers({ fetchUser: realFetchUser }, log), program)
626
+
627
+ // Replay run: never calls realFetchUser again, just replays recorded results.
628
+ await withHandlers(replayHandlers(["fetchUser"], log), program)
629
+ ```
630
+
631
+ `EffectLog` (`MemoryEffectLog`/`FileEffectLog`) is a small, storage-agnostic
632
+ interface (`record`/`replayNext`), mirroring `CheckpointStore` — an
633
+ extension point for a future backend, not a full production-grade
634
+ persistence system.
635
+
636
+ ### JavaScript interoperability
637
+
638
+ A handler is just a normal JavaScript function — sync or async — and can
639
+ call anything JavaScript can call:
640
+
641
+ ```jojo
642
+ effect fetchUser(id)
643
+
644
+ handle {
645
+ fetchUser: id => fetch(`/users/${id}`)
646
+ } {
647
+ ...
648
+ }
649
+ ```
650
+
651
+ ### Guarantees and limitations
652
+
653
+ - Handler dispatch is dynamic-scope-based (an `AsyncLocalStorage` stack),
654
+ the same mechanism `checkpoint`/`inspect`/metrics already use for
655
+ execution context. Because pipeline stages are lazy, a `handle { ... }`
656
+ block's handlers are only active while its body is *consumed* (e.g. via
657
+ `toArray`/`toArrayAsync`/`reduce`/...), not merely constructed — always
658
+ consume (or `await`) an effectful pipeline inside the `handle` block
659
+ itself.
660
+ - No delimited continuations: a handler cannot `resume` the computation
661
+ that invoked the effect, inspect intermediate state, or resume it more
662
+ than once.
663
+ - Recursion guard: a handler invoking the same effect it is currently
664
+ handling (directly or through another effect) is rejected with
665
+ `RecursiveEffectError` rather than hanging or overflowing the stack.
666
+ - Existing programs that never use `effect`/`handle` syntax, and existing
667
+ `parallel()` usage with no `PARALLEL` handler installed, are completely
668
+ unaffected — this is additive, opt-in syntax.
669
+
670
+
309
671
 
310
672
  JojoScript intentionally leaves ordinary JavaScript alone. You can use:
311
673
 
@@ -366,6 +728,12 @@ report, alongside one small focused module (and test) per feature. Every
366
728
  module is imported and invoked from `example/src/main.jojo`, so both
367
729
  `npm start` and `npm test` exercise the whole example.
368
730
 
731
+ [example/src/effects/](example/src/effects/) has six focused Effect Handlers
732
+ examples (each with a matching test under `example/test/`): basic effects,
733
+ running the same program with multiple handlers, deterministic testing,
734
+ `parallel()` as an effect, effect visibility in the execution plan, and the
735
+ checkpoint/resume interaction.
736
+
369
737
  ```bash
370
738
  cd example
371
739
  npm install
@@ -426,4 +794,16 @@ resulting `.vsix` locally.
426
794
  - **Multi-binding declarations (`a, b := 1, 2`) are single-line only** and
427
795
  always wrap the right-hand side in `[...]`; use `[a, b] := expr()` to
428
796
  destructure a single expression instead.
797
+ - **Effect Handlers are a simplified model, not full algebraic effects.**
798
+ No delimited continuations/`resume`; a handler cannot inspect or resume
799
+ the suspended computation, or resume it more than once. See the
800
+ "Effect Handlers" section for the full list of guarantees/limitations.
801
+ - **`handle { ... } { ... }` handlers are only active while their body is
802
+ actually consumed.** Constructing a lazy pipeline inside a `handle` block
803
+ without consuming it there (no `toArray`/`toArrayAsync`/`reduce`/...) means
804
+ effects pulled later, outside the block, won't see its handlers.
805
+ - **Only `PARALLEL` is a built-in effect today.** The other capabilities
806
+ mentioned as a long-term direction (`IO`, `HTTP`, `FILE`, `DATABASE`,
807
+ `SLEEP`, `TIME`, `RANDOM`, `CHECKPOINT`) are not implemented — user-defined
808
+ `effect` declarations cover the same need today.
429
809
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panagos/jojoscript",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A tiny JavaScript-compatible language layer with Jojo syntax and pipelines.",
5
5
  "license": "Unlicense",
6
6
  "type": "module",
package/src/compiler.js CHANGED
@@ -20,6 +20,12 @@ import {
20
20
 
21
21
  import { transformMatchExpressions } from './match-parser.js'
22
22
  import { transformTypeAnnotations } from './type-annotations.js'
23
+ import {
24
+ transformEffectDeclarations,
25
+ transformHandleBlocks,
26
+ findEffectDeclarations,
27
+ findHandleBlocks
28
+ } from './effect-parser.js'
23
29
 
24
30
  /*
25
31
  * The auto-generated runtime import must use whatever name this package is
@@ -54,6 +60,18 @@ export function compile(source, options = {}) {
54
60
  */
55
61
  result = transformMatchExpressions(result)
56
62
 
63
+ /*
64
+ * Effects: `effect name(params)` declarations and `handle { ... } { ... }`
65
+ * blocks. Detected before either transform runs (both remove the source
66
+ * syntax they recognize), so the compiler knows afterwards whether the
67
+ * `defineEffect`/`withHandlers` runtime helpers need to be auto-imported.
68
+ */
69
+ const usesEffectDeclarations = findEffectDeclarations(result).length > 0
70
+ const usesHandleBlocks = findHandleBlocks(result).length > 0
71
+
72
+ result = transformEffectDeclarations(result)
73
+ result = transformHandleBlocks(result)
74
+
57
75
  /*
58
76
  * Jojo declarations.
59
77
  */
@@ -69,6 +87,18 @@ export function compile(source, options = {}) {
69
87
  const pipelineStdlibFunctions =
70
88
  findPipelineStdlibFunctions(result)
71
89
 
90
+ /*
91
+ * `effect`/`handle` syntax is never written by the user as a runtime
92
+ * function call, so their required runtime helpers are added explicitly,
93
+ * the same way `traceNode` is added below for `jojo profile`.
94
+ */
95
+ if (usesEffectDeclarations && !pipelineStdlibFunctions.includes('defineEffect')) {
96
+ pipelineStdlibFunctions.push('defineEffect')
97
+ }
98
+ if (usesHandleBlocks && !pipelineStdlibFunctions.includes('withHandlers')) {
99
+ pipelineStdlibFunctions.push('withHandlers')
100
+ }
101
+
72
102
  /*
73
103
  * `jojo profile` compiles with `{ profile: true }`, which wraps every
74
104
  * pipeline stage (and the initial source expression) in a call to the
@@ -0,0 +1,223 @@
1
+ import { tokenize, significantTokens, maskNonCode } from './lexer.js'
2
+
3
+ /*
4
+ * Algebraic effects / effect handlers, source-level syntax:
5
+ *
6
+ * effect fetchUser(id)
7
+ *
8
+ * handle {
9
+ * fetchUser: id => http.get(`/users/${id}`)
10
+ * } {
11
+ * users |> map(user => fetchUser(user.id))
12
+ * }
13
+ *
14
+ * Both constructs compile to plain calls into the `@panagos/jojoscript/runtime`
15
+ * effect runtime (`defineEffect`/`withHandlers`), following the same
16
+ * "regex/token transform, no real AST" philosophy as `match-parser.js` and
17
+ * `transforms.js` rather than introducing a parallel parsing architecture.
18
+ */
19
+
20
+ function text(source, tokens) {
21
+ if (!tokens.length) return ''
22
+ return source.slice(tokens[0].start, tokens[tokens.length - 1].end)
23
+ }
24
+
25
+ /*
26
+ * Only applies `regex` to matches that are real code (not inside a string,
27
+ * template literal, or comment). Mirrors `transforms.js`'s private
28
+ * `codeAwareReplace` helper; duplicated here (rather than exported from
29
+ * `transforms.js`) to keep each transform module self-contained.
30
+ */
31
+ function codeAwareReplace(source, regex, replacer) {
32
+ const mask = maskNonCode(source)
33
+
34
+ return source.replace(regex, (...args) => {
35
+ const match = args[0]
36
+ const offset = args[args.length - 2]
37
+
38
+ if (mask.slice(offset, offset + match.length) !== match) {
39
+ return match
40
+ }
41
+
42
+ return replacer(...args)
43
+ })
44
+ }
45
+
46
+ /*
47
+ * A deliberately simple, single-line declaration: `effect name(params)`,
48
+ * optionally `export`ed. Parameter names are plain identifiers (no default
49
+ * values/destructuring/nested parens) — enough to declare an effect's
50
+ * shape without needing a full expression parser.
51
+ */
52
+ const EFFECT_DECLARATION = /^([ \t]*)(export\s+)?effect\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^()]*)\)\s*;?[ \t]*$/gm
53
+
54
+ function splitParams(params) {
55
+ return params.trim() ? params.split(',').map(p => p.trim()).filter(Boolean) : []
56
+ }
57
+
58
+ /*
59
+ * Static scan for `effect name(params)` declarations. Used both by the
60
+ * compiler (to know which runtime imports are needed) and by pipeline
61
+ * inspection (`pipeline-ir.js`) to recognize effect names referenced from
62
+ * inside pipeline stages.
63
+ */
64
+ export function findEffectDeclarations(source) {
65
+ const mask = maskNonCode(source)
66
+ const regex = new RegExp(EFFECT_DECLARATION.source, EFFECT_DECLARATION.flags)
67
+ const results = []
68
+ let match
69
+
70
+ while ((match = regex.exec(source))) {
71
+ const offset = match.index
72
+ if (mask.slice(offset, offset + match[0].length) !== match[0]) continue
73
+
74
+ results.push({
75
+ name: match[3],
76
+ params: splitParams(match[4]),
77
+ start: offset,
78
+ end: offset + match[0].length
79
+ })
80
+ }
81
+
82
+ return results
83
+ }
84
+
85
+ export function transformEffectDeclarations(source) {
86
+ return codeAwareReplace(source, EFFECT_DECLARATION, (match, indent, exportPart, name, params) => {
87
+ const paramList = JSON.stringify(splitParams(params))
88
+ return `${indent}${exportPart || ''}const ${name} = defineEffect(${JSON.stringify(name)}, ${paramList})`
89
+ })
90
+ }
91
+
92
+ /*
93
+ * Distinguishes the `handle` keyword from ordinary identifiers named
94
+ * "handle" (e.g. `stream.handle(...)`, `const handle = ...`).
95
+ */
96
+ function isHandleKeyword(tokens, index) {
97
+ const token = tokens[index]
98
+ if (token.type !== 'identifier' || token.value !== 'handle') return false
99
+
100
+ const prev = tokens[index - 1]
101
+ if (prev && ['.', 'const', 'let', 'var', 'function', 'class'].includes(prev.value)) return false
102
+
103
+ const next = tokens[index + 1]
104
+ if (!next || next.value !== '{') return false
105
+
106
+ return true
107
+ }
108
+
109
+ function matchingBrace(tokens, openIndex) {
110
+ let depth = 0
111
+ for (let i = openIndex; i < tokens.length; i++) {
112
+ if (tokens[i].value === '{') depth++
113
+ else if (tokens[i].value === '}') {
114
+ depth--
115
+ if (depth === 0) return i
116
+ }
117
+ }
118
+ throw new SyntaxError('Unclosed `handle` block')
119
+ }
120
+
121
+ function splitTopLevelCommas(tokens) {
122
+ const result = []
123
+ let start = 0
124
+ let depth = 0
125
+
126
+ for (let i = 0; i < tokens.length; i++) {
127
+ const value = tokens[i].value
128
+ if (value === '(' || value === '[' || value === '{') depth++
129
+ else if (value === ')' || value === ']' || value === '}') depth--
130
+ else if (value === ',' && depth === 0) {
131
+ result.push(tokens.slice(start, i))
132
+ start = i + 1
133
+ }
134
+ }
135
+
136
+ if (start < tokens.length) result.push(tokens.slice(start))
137
+ return result
138
+ }
139
+
140
+ function extractHandlerNames(source, tokens) {
141
+ return splitTopLevelCommas(tokens)
142
+ .filter(group => group.length)
143
+ .map(group => {
144
+ const first = group[0]
145
+ if (first.type === 'identifier') return first.value
146
+ if (first.type === 'string') return first.value.slice(1, -1)
147
+ throw new SyntaxError(`Invalid handler entry in \`handle { ... }\`: ${text(source, group)}`)
148
+ })
149
+ }
150
+
151
+ function locateHandleBlock(tokens, index) {
152
+ const handlerOpen = index + 1
153
+ const handlerClose = matchingBrace(tokens, handlerOpen)
154
+
155
+ const bodyOpen = handlerClose + 1
156
+ if (!tokens[bodyOpen] || tokens[bodyOpen].value !== '{') {
157
+ throw new SyntaxError('`handle { ... }` must be followed by a body block: `handle { ... } { ... }`')
158
+ }
159
+ const bodyClose = matchingBrace(tokens, bodyOpen)
160
+
161
+ return { keywordIndex: index, handlerOpen, handlerClose, bodyOpen, bodyClose }
162
+ }
163
+
164
+ /*
165
+ * Static scan for every `handle { handlers } { body }` block in `source`,
166
+ * used both by the compiler and by pipeline inspection (which handler
167
+ * names are statically declared, for a best-effort "is this effect
168
+ * handled?" signal).
169
+ */
170
+ export function findHandleBlocks(source) {
171
+ const tokens = significantTokens(tokenize(source))
172
+ const blocks = []
173
+
174
+ for (let i = 0; i < tokens.length; i++) {
175
+ if (!isHandleKeyword(tokens, i)) continue
176
+
177
+ const block = locateHandleBlock(tokens, i)
178
+ blocks.push({
179
+ start: tokens[block.keywordIndex].start,
180
+ end: tokens[block.bodyClose].end,
181
+ handlerNames: extractHandlerNames(source, tokens.slice(block.handlerOpen + 1, block.handlerClose))
182
+ })
183
+ }
184
+
185
+ return blocks
186
+ }
187
+
188
+ /*
189
+ * `handle { handlers } { body }` compiles to:
190
+ *
191
+ * (await withHandlers({ handlers }, async () => { body }))
192
+ *
193
+ * Processes the rightmost (innermost, for nested `handle` blocks) match
194
+ * first on every iteration, same strategy as `transformMatchExpressions`,
195
+ * so nested handlers compile inside-out.
196
+ */
197
+ export function transformHandleBlocks(source) {
198
+ let result = source
199
+
200
+ for (let iteration = 0; iteration < 1000; iteration++) {
201
+ const tokens = significantTokens(tokenize(result))
202
+
203
+ let target = null
204
+ for (let i = tokens.length - 1; i >= 0; i--) {
205
+ if (!isHandleKeyword(tokens, i)) continue
206
+ target = locateHandleBlock(tokens, i)
207
+ break
208
+ }
209
+
210
+ if (!target) return result
211
+
212
+ const handlerText = text(result, tokens.slice(target.handlerOpen, target.handlerClose + 1))
213
+ const bodyText = text(result, tokens.slice(target.bodyOpen, target.bodyClose + 1))
214
+ const replacement = `(await withHandlers(${handlerText}, async () => ${bodyText}))`
215
+
216
+ const start = tokens[target.keywordIndex].start
217
+ const end = tokens[target.bodyClose].end
218
+
219
+ result = result.slice(0, start) + replacement + result.slice(end)
220
+ }
221
+
222
+ throw new SyntaxError('handle transformation exceeded 1000 iterations')
223
+ }
package/src/index.js CHANGED
@@ -17,3 +17,17 @@ export {
17
17
  export {
18
18
  transformTypeAnnotations
19
19
  } from './type-annotations.js'
20
+
21
+ export {
22
+ analyzePipelines,
23
+ buildGraph,
24
+ renderGraphText,
25
+ renderGraphDot
26
+ } from './pipeline-ir.js'
27
+
28
+ export {
29
+ transformEffectDeclarations,
30
+ transformHandleBlocks,
31
+ findEffectDeclarations,
32
+ findHandleBlocks
33
+ } from './effect-parser.js'
@@ -1,5 +1,20 @@
1
1
  import { findAllPipelines } from './pipeline-parser.js'
2
2
  import { computePipelineId } from './pipeline-hash.js'
3
+ import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
4
+
5
+ /*
6
+ * Best-effort, purely textual check for whether `effectName` is referenced
7
+ * anywhere in a stage's call (its name or its argument expressions), e.g.
8
+ * `map(user => fetchUser(user.id))` or a bare `saveUser` stage. This is
9
+ * static analysis over source text, not evaluation — it cannot tell
10
+ * whether the reference is actually reachable/called, only that the name
11
+ * appears with word boundaries.
12
+ */
13
+ function effectsUsedByStage(stage, effectNames) {
14
+ if (!effectNames.length) return []
15
+ const haystack = stage.args.length ? `${stage.name} ${stage.args.join(' ')}` : stage.name
16
+ return effectNames.filter(name => new RegExp(`\\b${name}\\b`).test(haystack))
17
+ }
3
18
 
4
19
  /*
5
20
  * Static, execution-free representation of a pipeline: SOURCE, one node per
@@ -8,8 +23,15 @@ import { computePipelineId } from './pipeline-hash.js'
8
23
  * `jojo profile`'s collected metrics via the same `node-<n>` id scheme the
9
24
  * compiler uses when emitting code (see `pipeline-parser.js`'s
10
25
  * `emitPipeline`).
26
+ *
27
+ * `options.effectNames`/`options.handledEffectNames` (both plain arrays of
28
+ * effect names) let callers surface effect usage per node (`jojo graph`
29
+ * passes the whole file's `effect name(...)` declarations and any
30
+ * statically-visible `handle { name: ... }` keys) — see `analyzePipelines`.
11
31
  */
12
- export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline)) {
32
+ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline), options = {}) {
33
+ const effectNames = options.effectNames ?? []
34
+ const handledEffectNames = new Set(options.handledEffectNames ?? [])
13
35
  const nodes = []
14
36
 
15
37
  nodes.push({
@@ -22,13 +44,15 @@ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline)) {
22
44
  })
23
45
 
24
46
  pipeline.stages.forEach((stage, index) => {
47
+ const effects = effectsUsedByStage(stage, effectNames)
48
+
25
49
  nodes.push({
26
50
  id: `node-${index + 1}`,
27
51
  type: stage.name,
28
52
  label: stage.args.length ? `${stage.name}(${stage.args.join(', ')})` : stage.name,
29
53
  args: stage.args,
30
54
  dependencies: [`node-${index}`],
31
- metadata: {}
55
+ metadata: effects.length ? { effects } : {}
32
56
  })
33
57
  })
34
58
 
@@ -42,15 +66,39 @@ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline)) {
42
66
  metadata: {}
43
67
  })
44
68
 
45
- return { id: pipelineId, expression: pipeline.expression.trim(), nodes }
69
+ /*
70
+ * Per-effect summary across the whole pipeline: name, which node(s)
71
+ * reference it, and a best-effort static "handled" signal — true only
72
+ * if a `handle { name: ... }` block naming this effect was found
73
+ * anywhere in the same file. A statically-declared handle block that is
74
+ * conditionally executed, or a handler installed dynamically at
75
+ * runtime, cannot be seen here; this is inspection, not proof.
76
+ */
77
+ const effects = nodes
78
+ .filter(node => node.metadata.effects?.length)
79
+ .flatMap(node => node.metadata.effects.map(name => ({
80
+ name,
81
+ nodeId: node.id,
82
+ staticallyHandled: handledEffectNames.has(name)
83
+ })))
84
+
85
+ return { id: pipelineId, expression: pipeline.expression.trim(), nodes, effects }
46
86
  }
47
87
 
48
88
  /*
49
89
  * Parses every pipeline statement in `source` and returns their graphs,
50
- * each with a deterministic id derived from its normalized structure.
90
+ * each with a deterministic id derived from its normalized structure. Also
91
+ * scans `source` for `effect name(...)` declarations and `handle { ... }`
92
+ * handler-map keys, so returned graphs can expose which effects each
93
+ * pipeline uses and whether a handler for them is statically visible.
51
94
  */
52
95
  export function analyzePipelines(source) {
53
- return findAllPipelines(source).map(pipeline => buildGraph(pipeline))
96
+ const effectNames = findEffectDeclarations(source).map(declaration => declaration.name)
97
+ const handledEffectNames = findHandleBlocks(source).flatMap(block => block.handlerNames)
98
+
99
+ return findAllPipelines(source).map(pipeline =>
100
+ buildGraph(pipeline, undefined, { effectNames, handledEffectNames })
101
+ )
54
102
  }
55
103
 
56
104
  export function renderGraphText(graph) {
@@ -68,6 +116,10 @@ export function renderGraphText(graph) {
68
116
  : node.type.toUpperCase()
69
117
  }
70
118
 
119
+ if (node.metadata.effects?.length) {
120
+ label += ` [effects: ${node.metadata.effects.join(', ')}]`
121
+ }
122
+
71
123
  lines.push(label)
72
124
 
73
125
  if (index < graph.nodes.length - 1) {
@@ -83,9 +135,14 @@ export function renderGraphDot(graph) {
83
135
  const lines = ['digraph pipeline {']
84
136
 
85
137
  for (const node of graph.nodes) {
86
- const dotLabel = node.args.length
138
+ let dotLabel = node.args.length
87
139
  ? `${node.type}\\n${node.args.join(', ').replace(/"/g, '\\"')}`
88
140
  : node.type
141
+
142
+ if (node.metadata.effects?.length) {
143
+ dotLabel += `\\n[effects: ${node.metadata.effects.join(', ')}]`
144
+ }
145
+
89
146
  lines.push(` "${node.id}" [label="${dotLabel}"];`)
90
147
  }
91
148
 
@@ -1,3 +1,5 @@
1
+ import { getParallelHandler } from './effects.js'
2
+
1
3
  function iterable(value) {
2
4
  if (value == null) {
3
5
  throw new TypeError('Expected an iterable, got null or undefined')
@@ -141,9 +143,36 @@ async function* mapConcurrent(items, fn, concurrency) {
141
143
  }
142
144
  }
143
145
 
146
+ /*
147
+ * A `PARALLEL` handler may be an ordinary sync function returning an
148
+ * array, an async function returning a Promise of an (async) iterable, or
149
+ * an async generator function returning an async iterable directly (no
150
+ * Promise wrapper at all). This normalizes all three into a single async
151
+ * iterable so `map()`'s return contract stays the same either way.
152
+ */
153
+ async function* toAsyncIterable(resultOrPromise) {
154
+ const result = await resultOrPromise
155
+ for await (const item of result) yield item
156
+ }
157
+
144
158
  export function map(items, fn) {
145
159
  if (isParallelTagged(items)) {
146
- return mapConcurrent(items.source, fn, items[PARALLEL_TAG])
160
+ const concurrency = items[PARALLEL_TAG]
161
+ const handler = getParallelHandler()
162
+
163
+ /*
164
+ * `parallel(n)` is effect-backed: a `PARALLEL` handler installed via
165
+ * `handle { PARALLEL: ... } { ... }` decides how this actually runs
166
+ * (real concurrency, deterministic sequential execution, recorded
167
+ * scheduling, ...) instead of the default bounded-concurrency worker
168
+ * pool below. With no handler installed — the common case — behavior
169
+ * is exactly what it was before effects existed.
170
+ */
171
+ if (handler) {
172
+ return toAsyncIterable(handler({ items: items.source, fn, concurrency, meta: items.meta }))
173
+ }
174
+
175
+ return mapConcurrent(items.source, fn, concurrency)
147
176
  }
148
177
  return (function* () {
149
178
  let index = 0
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /*
5
+ * Storage interface for recorded effect calls, mirroring
6
+ * `checkpoint-store.js`'s `CheckpointStore`: the record/replay handler
7
+ * wrappers in `effects.js` only ever talk to this interface, never to a
8
+ * filesystem/database directly, so alternative backends can be added later
9
+ * without touching the effect runtime.
10
+ */
11
+ export class EffectLog {
12
+ async record(_call) {
13
+ throw new Error('EffectLog.record() is not implemented')
14
+ }
15
+
16
+ async replayNext(_name) {
17
+ throw new Error('EffectLog.replayNext() is not implemented')
18
+ }
19
+ }
20
+
21
+ /*
22
+ * In-memory log. `replayNext(name)` returns recorded calls for that effect
23
+ * name in the order they were recorded, one per call — the same effect
24
+ * invoked three times during recording replays its three results in order
25
+ * during replay, regardless of what other effects were interleaved.
26
+ */
27
+ export class MemoryEffectLog extends EffectLog {
28
+ constructor(entries = []) {
29
+ super()
30
+ this.entries = [...entries]
31
+ this.cursor = new Map()
32
+ }
33
+
34
+ async record(call) {
35
+ this.entries.push(call)
36
+ }
37
+
38
+ async replayNext(name) {
39
+ const index = this.cursor.get(name) ?? 0
40
+ const matches = this.entries.filter(entry => entry.name === name)
41
+
42
+ if (index >= matches.length) {
43
+ throw new Error(`No recorded call left to replay for effect "${name}"`)
44
+ }
45
+
46
+ this.cursor.set(name, index + 1)
47
+ return matches[index]
48
+ }
49
+
50
+ toJSON() {
51
+ return this.entries
52
+ }
53
+ }
54
+
55
+ /*
56
+ * JSON-on-disk log, one file for the whole recording. Like
57
+ * `FileCheckpointStore`, writes go to a temp file first and are then
58
+ * renamed into place so a crash mid-write never corrupts the log.
59
+ */
60
+ export class FileEffectLog extends EffectLog {
61
+ constructor(file) {
62
+ super()
63
+ this.file = file
64
+ this.entries = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : []
65
+ this.cursor = new Map()
66
+ }
67
+
68
+ async record(call) {
69
+ this.entries.push(call)
70
+
71
+ const dir = path.dirname(this.file)
72
+ fs.mkdirSync(dir, { recursive: true })
73
+
74
+ const tmp = `${this.file}.tmp-${process.pid}-${Date.now()}`
75
+ fs.writeFileSync(tmp, JSON.stringify(this.entries, null, 2))
76
+ fs.renameSync(tmp, this.file)
77
+ }
78
+
79
+ async replayNext(name) {
80
+ const index = this.cursor.get(name) ?? 0
81
+ const matches = this.entries.filter(entry => entry.name === name)
82
+
83
+ if (index >= matches.length) {
84
+ throw new Error(`No recorded call left to replay for effect "${name}"`)
85
+ }
86
+
87
+ this.cursor.set(name, index + 1)
88
+ return matches[index]
89
+ }
90
+ }
@@ -0,0 +1,263 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ /*
4
+ * Algebraic effects / effect handlers runtime.
5
+ *
6
+ * The core idea: `defineEffect` turns a name into a callable that *describes
7
+ * an intent* ("perform fetchUser(42)") without deciding how it is carried
8
+ * out. `withHandlers` installs a handler map for the dynamic extent of a
9
+ * callback (an async-local, stack-shaped scope, exactly like
10
+ * `execution-context.js` already does for checkpoint/metrics state), and
11
+ * `performEffect` resolves an effect call against the innermost matching
12
+ * handler at the moment it is invoked.
13
+ *
14
+ * This is deliberately a *simplified* effect-handler model, not full
15
+ * algebraic effects: there is no delimited-continuation `resume`, handlers
16
+ * cannot resume a suspended computation more than once (or zero times) and
17
+ * inspect the result, and effects cannot be "re-thrown" to an outer handler
18
+ * after partial handling. What it *does* provide: named effects, dynamic
19
+ * handler dispatch, handler nesting/overriding, deterministic-vs-real
20
+ * handler swapping, and a clean async/JS interop story (a handler is just
21
+ * a normal — sync or async — JavaScript function).
22
+ */
23
+
24
+ const handlerStorage = new AsyncLocalStorage()
25
+ const invocationStorage = new AsyncLocalStorage()
26
+
27
+ /*
28
+ * name -> { name, params, sideEffecting, replayable, builtin? }.
29
+ * Populated by every `defineEffect` call (i.e. every `effect name(...)`
30
+ * declaration compiles into one), plus the built-in `PARALLEL` capability.
31
+ * Purely descriptive metadata for inspection — never consulted by
32
+ * dispatch itself.
33
+ */
34
+ const effectRegistry = new Map()
35
+
36
+ export class UnhandledEffectError extends Error {
37
+ constructor(name) {
38
+ super(`Unhandled effect: ${name}\nNo handler registered for effect "${name}"`)
39
+ this.name = 'UnhandledEffectError'
40
+ this.effectName = name
41
+ }
42
+ }
43
+
44
+ export class EffectHandlerError extends Error {
45
+ constructor(name, cause) {
46
+ super(`Effect handler for "${name}" threw: ${cause?.message ?? cause}`, { cause })
47
+ this.name = 'EffectHandlerError'
48
+ this.effectName = name
49
+ }
50
+ }
51
+
52
+ export class RecursiveEffectError extends Error {
53
+ constructor(name) {
54
+ super(`Effect handler for "${name}" recursively invoked the same effect ("${name}")`)
55
+ this.name = 'RecursiveEffectError'
56
+ this.effectName = name
57
+ }
58
+ }
59
+
60
+ /*
61
+ * Declares a named effect and returns the callable used to *perform* it.
62
+ * `options.sideEffecting` (default true) and `options.replayable` (default
63
+ * false) are pure metadata consumed by inspection (`describeEffect`,
64
+ * `describeHandlers`) and by callers deciding whether it's safe to treat a
65
+ * result as reusable across a checkpoint/replay boundary — the runtime
66
+ * itself never enforces them.
67
+ */
68
+ export function defineEffect(name, params = [], options = {}) {
69
+ effectRegistry.set(name, {
70
+ name,
71
+ params: [...params],
72
+ sideEffecting: options.sideEffecting ?? true,
73
+ replayable: options.replayable ?? false
74
+ })
75
+
76
+ function effectFn(...args) {
77
+ return performEffect(name, args)
78
+ }
79
+
80
+ effectFn.effectName = name
81
+ effectFn.isEffect = true
82
+ return effectFn
83
+ }
84
+
85
+ export function describeEffect(name) {
86
+ return effectRegistry.get(name) ?? null
87
+ }
88
+
89
+ export function listEffects() {
90
+ return [...effectRegistry.values()]
91
+ }
92
+
93
+ function normalizeHandlerEntry(name, value) {
94
+ if (typeof value === 'function') {
95
+ return { handle: value, deterministic: undefined }
96
+ }
97
+
98
+ if (value && typeof value === 'object' && typeof value.handle === 'function') {
99
+ return { handle: value.handle, deterministic: value.deterministic ?? undefined }
100
+ }
101
+
102
+ throw new TypeError(
103
+ `Invalid handler for effect "${name}": expected a function, e.g. \`id => fakeUser(id)\`, ` +
104
+ 'or a `{ handle, deterministic }` descriptor'
105
+ )
106
+ }
107
+
108
+ function currentFrames() {
109
+ return handlerStorage.getStore() ?? []
110
+ }
111
+
112
+ /*
113
+ * Installs `handlers` (an object of `name -> handlerFn | { handle,
114
+ * deterministic }`) for the dynamic extent of `fn`, then restores whatever
115
+ * was active before. Nesting `withHandlers` calls (source: nested `handle`
116
+ * blocks) pushes a new innermost frame in front of the existing ones, so an
117
+ * inner handler for the same effect name shadows — overrides — an outer
118
+ * one, while effects the inner handler map does *not* mention still fall
119
+ * through to the outer handler.
120
+ *
121
+ * Caveat (shared with `execution-context.js`'s checkpoint/metrics context):
122
+ * because pipeline stages like `map`/`filter` are lazy generators, the
123
+ * handler frame is only active while `fn` is *synchronously/async-chain*
124
+ * executing. If `fn` merely constructs a lazy pipeline and returns it
125
+ * without consuming it (no `toArray`/`toArrayAsync`/`reduce`/...), items
126
+ * pulled later outside this call will see whatever handlers (if any) are
127
+ * active at that later point, not these ones. Always consume (or `await`)
128
+ * effectful pipelines inside the `handle { ... } { ... }` block itself.
129
+ */
130
+ export function withHandlers(handlers, fn) {
131
+ if (!handlers || typeof handlers !== 'object') {
132
+ throw new TypeError('withHandlers requires a handler map object, e.g. { effectName: handlerFn }')
133
+ }
134
+
135
+ const normalized = {}
136
+ for (const [name, value] of Object.entries(handlers)) {
137
+ normalized[name] = normalizeHandlerEntry(name, value)
138
+ }
139
+
140
+ const frames = [{ handlers: normalized }, ...currentFrames()]
141
+ return handlerStorage.run(frames, fn)
142
+ }
143
+
144
+ /*
145
+ * Resolves and invokes the effect named `name` against the innermost
146
+ * handler that declares it. Always returns a Promise (even for a
147
+ * synchronous handler) so calling code never needs to know whether the
148
+ * active handler is sync or async — production (often async: HTTP/DB) and
149
+ * test (often sync: fake data) handlers are interchangeable.
150
+ */
151
+ export async function performEffect(name, args) {
152
+ const inFlight = invocationStorage.getStore() ?? new Set()
153
+ if (inFlight.has(name)) {
154
+ throw new RecursiveEffectError(name)
155
+ }
156
+
157
+ const frame = currentFrames().find(f => name in f.handlers)
158
+ if (!frame) {
159
+ throw new UnhandledEffectError(name)
160
+ }
161
+
162
+ const entry = frame.handlers[name]
163
+ const nextInFlight = new Set(inFlight)
164
+ nextInFlight.add(name)
165
+
166
+ try {
167
+ return await invocationStorage.run(nextInFlight, () => entry.handle(...args))
168
+ } catch (error) {
169
+ if (error instanceof RecursiveEffectError || error instanceof UnhandledEffectError) throw error
170
+ throw new EffectHandlerError(name, error)
171
+ }
172
+ }
173
+
174
+ /*
175
+ * Introspection: which effects have an active handler *right now* (i.e.
176
+ * called from inside a `handle { ... } { ... }` block), and what is known
177
+ * about each — handled, whether the installed handler declared itself
178
+ * deterministic, and the declaring effect's `sideEffecting`/`replayable`
179
+ * metadata. Returns `[]` outside any `withHandlers` scope.
180
+ */
181
+ export function describeHandlers() {
182
+ const seen = new Map()
183
+
184
+ for (const frame of currentFrames()) {
185
+ for (const [name, entry] of Object.entries(frame.handlers)) {
186
+ if (seen.has(name)) continue
187
+
188
+ const registryEntry = effectRegistry.get(name)
189
+ seen.set(name, {
190
+ name,
191
+ handled: true,
192
+ deterministic: entry.deterministic ?? null,
193
+ sideEffecting: registryEntry?.sideEffecting ?? null,
194
+ replayable: registryEntry?.replayable ?? null
195
+ })
196
+ }
197
+ }
198
+
199
+ return [...seen.values()]
200
+ }
201
+
202
+ /*
203
+ * `parallel(n)` as an effect-backed capability (see collections.js' `map`):
204
+ * if a `PARALLEL` handler is currently installed, it decides how
205
+ * `items |> parallel(n) |> map(fn)` actually runs (real concurrency,
206
+ * deterministic sequential execution, recorded scheduling, ...). With no
207
+ * handler installed, `map` falls back to the original bounded-concurrency
208
+ * implementation — existing programs that never touch `handle {}` are
209
+ * completely unaffected.
210
+ */
211
+ effectRegistry.set('PARALLEL', {
212
+ name: 'PARALLEL',
213
+ params: ['concurrency'],
214
+ sideEffecting: true,
215
+ replayable: false,
216
+ builtin: true
217
+ })
218
+
219
+ export function getParallelHandler() {
220
+ const frame = currentFrames().find(f => 'PARALLEL' in f.handlers)
221
+ return frame ? frame.handlers.PARALLEL.handle : null
222
+ }
223
+
224
+ /*
225
+ * Record/replay extension point (see effect-log.js for the storage side).
226
+ * `recordHandlers` wraps a normal (typically production) handler map so
227
+ * every call's arguments and result are appended to `log`; `replayHandlers`
228
+ * builds a handler map that never runs real code at all, just replays
229
+ * recorded results for the given effect names, in call order, per effect.
230
+ */
231
+ export function recordHandlers(handlers, log) {
232
+ const wrapped = {}
233
+
234
+ for (const [name, value] of Object.entries(handlers)) {
235
+ const entry = normalizeHandlerEntry(name, value)
236
+ wrapped[name] = {
237
+ deterministic: entry.deterministic,
238
+ handle: async (...args) => {
239
+ const result = await entry.handle(...args)
240
+ await log.record({ name, args, result, recordedAt: new Date().toISOString() })
241
+ return result
242
+ }
243
+ }
244
+ }
245
+
246
+ return wrapped
247
+ }
248
+
249
+ export function replayHandlers(effectNames, log) {
250
+ const wrapped = {}
251
+
252
+ for (const name of effectNames) {
253
+ wrapped[name] = {
254
+ deterministic: true,
255
+ handle: async () => {
256
+ const call = await log.replayNext(name)
257
+ return call.result
258
+ }
259
+ }
260
+ }
261
+
262
+ return wrapped
263
+ }
@@ -48,4 +48,23 @@ export {
48
48
  getDefaultContext,
49
49
  resetDefaultContext
50
50
  } from './execution-context.js'
51
+ export {
52
+ defineEffect,
53
+ withHandlers,
54
+ performEffect,
55
+ describeEffect,
56
+ listEffects,
57
+ describeHandlers,
58
+ getParallelHandler,
59
+ recordHandlers,
60
+ replayHandlers,
61
+ UnhandledEffectError,
62
+ EffectHandlerError,
63
+ RecursiveEffectError
64
+ } from './effects.js'
65
+ export {
66
+ EffectLog,
67
+ MemoryEffectLog,
68
+ FileEffectLog
69
+ } from './effect-log.js'
51
70