@panagos/jojoscript 0.8.0 → 0.9.1

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.1
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
@@ -236,6 +237,26 @@ becomes:
236
237
  divide(100, value)
237
238
  ```
238
239
 
240
+ #### Property access stages
241
+
242
+ A pipeline stage may also start with a dot to read a field or invoke a method
243
+ on the piped value directly:
244
+
245
+ ```jojo
246
+ users |> .length
247
+ name |> .trim() |> .toUpperCase()
248
+ ```
249
+
250
+ becomes:
251
+
252
+ ```js
253
+ users.length
254
+ name.trim().toUpperCase()
255
+ ```
256
+
257
+ This is handy for compact pipelines where the previous value is the receiver of
258
+ an ordinary JavaScript property or method call.
259
+
239
260
  #### `catch` / `orElse` control stages
240
261
 
241
262
  `catch` and `orElse` provide inline error handling without leaving pipeline
@@ -305,7 +326,368 @@ auto-imported):
305
326
  See [ITERATOR_PROPOSAL.md](ITERATOR_PROPOSAL.md) and [ITERATORS.md](ITERATORS.md)
306
327
  for the full laziness/memory model.
307
328
 
308
- ## JavaScript compatibility
329
+ ## Effect Handlers
330
+
331
+ > This is a deliberately simplified effect-handler model, not full
332
+ > algebraic effects with delimited continuations (no `resume`, no
333
+ > resuming a suspended computation zero/more-than-once, no re-throwing to
334
+ > an outer handler after partial handling). What it *does* give you: named
335
+ > effects, dynamic handler dispatch, nesting/overriding, deterministic
336
+ > mock handlers, record/replay, and a `parallel()` effect — all with plain
337
+ > JavaScript interop. If you already know algebraic effects from OCaml/Eff/
338
+ > Koka, think "a practical subset", not "the whole theory".
339
+
340
+ The central idea:
341
+
342
+ ```
343
+ Pipeline
344
+ describes computation
345
+
346
+ Execution Plan
347
+ describes how the computation can be executed
348
+
349
+ Effect
350
+ describes an external capability/action
351
+
352
+ Effect Handler
353
+ decides how that capability/action is interpreted
354
+ ```
355
+
356
+ **Describe the computation once, choose its interpretation separately.**
357
+
358
+ ### What an effect is
359
+
360
+ `effect name(params)` declares a named capability — "this program wants to
361
+ fetch a user", not "this program makes an HTTP GET request". It compiles to
362
+ a plain runtime call:
363
+
364
+ ```jojo
365
+ effect fetchUser(id)
366
+ effect saveUser(user)
367
+ ```
368
+
369
+ becomes:
370
+
371
+ ```js
372
+ const fetchUser = defineEffect("fetchUser", ["id"])
373
+ const saveUser = defineEffect("saveUser", ["user"])
374
+ ```
375
+
376
+ Calling `fetchUser(id)` doesn't run anything itself — it looks up the
377
+ currently active handler for `"fetchUser"` and delegates to it. **Calling an
378
+ effect always returns a Promise**, even if the active handler is
379
+ synchronous, so the calling code never has to know or care whether the
380
+ handler in charge is a real (often async) implementation or a fake (often
381
+ sync) one.
382
+
383
+ ### What an effect handler is, and how to install one
384
+
385
+ A handler supplies the implementation for one or more named effects, for
386
+ the dynamic extent of a block:
387
+
388
+ ```jojo
389
+ effect fetchUser(id)
390
+ effect saveUser(user)
391
+
392
+ users
393
+ |> map(user => fetchUser(user.id))
394
+ |> map(saveUser)
395
+ ```
396
+
397
+ runs under:
398
+
399
+ ```jojo
400
+ handle {
401
+ fetchUser: id => http.get(`/users/${id}`),
402
+ saveUser: user => database.save(user)
403
+ } {
404
+ users
405
+ |> map(user => fetchUser(user.id))
406
+ |> map(saveUser)
407
+ }
408
+ ```
409
+
410
+ which compiles to:
411
+
412
+ ```js
413
+ (await withHandlers({
414
+ fetchUser: id => http.get(`/users/${id}`),
415
+ saveUser: user => database.save(user)
416
+ }, async () => {
417
+ users
418
+ |> map(user => fetchUser(user.id))
419
+ |> map(saveUser)
420
+ }))
421
+ ```
422
+
423
+ A handler value can be a plain function, or a `{ handle, deterministic }`
424
+ descriptor when you want to declare it explicitly deterministic (useful for
425
+ inspection — see below):
426
+
427
+ ```jojo
428
+ handle {
429
+ fetchUser: { handle: id => fakeUser(id), deterministic: true }
430
+ } {
431
+ ...
432
+ }
433
+ ```
434
+
435
+ Because `handle { ... } { ... }` compiles to `await withHandlers(...)`, it
436
+ must be used inside an `async fn`/`async function`, or at the top level of
437
+ an ES module (Node's top-level `await`) — the same requirement the existing
438
+ `|> await` pipeline stage already has.
439
+
440
+ ### How the same program runs under different handlers
441
+
442
+ The program never mentions which handler is active — only the caller
443
+ decides that, by choosing which `handle { ... }` wraps it:
444
+
445
+ ```jojo
446
+ effect fetchUser(id)
447
+ effect saveUser(user)
448
+
449
+ async fn syncUser(id) {
450
+ user := await fetchUser(id)
451
+ return await saveUser(user)
452
+ }
453
+
454
+ async fn syncUserInProduction(id) {
455
+ return handle {
456
+ fetchUser: id => http.get(`/users/${id}`),
457
+ saveUser: user => database.save(user)
458
+ } {
459
+ return await syncUser(id)
460
+ }
461
+ }
462
+
463
+ async fn syncUserInTests(id) {
464
+ return handle {
465
+ fetchUser: id => ({ id, name: "Fake User" }),
466
+ saveUser: user => ({ ...user, savedTo: "in-memory" })
467
+ } {
468
+ return await syncUser(id)
469
+ }
470
+ }
471
+ ```
472
+
473
+ `syncUser` is identical in both cases; only the handler map differs. See
474
+ [example/src/effects/multiple-handlers.jojo](example/src/effects/multiple-handlers.jojo)
475
+ and
476
+ [example/src/effects/deterministic-testing.jojo](example/src/effects/deterministic-testing.jojo).
477
+
478
+ ### Nested handlers and resolution rules
479
+
480
+ `handle` blocks nest. An inner handler for a given effect name **overrides**
481
+ an outer one; an inner handler map that doesn't mention an effect leaves it
482
+ falling through to the next outer handler that does:
483
+
484
+ ```jojo
485
+ handle {
486
+ fetchUser: outerFake
487
+ } {
488
+ handle {
489
+ fetchUser: innerFake
490
+ } {
491
+ users |> map(fetchUser) // resolves to innerFake
492
+ }
493
+
494
+ users |> map(fetchUser) // resolves to outerFake, outside the inner block
495
+ }
496
+ ```
497
+
498
+ Resolution walks from the innermost active `handle` block outward, taking
499
+ the first one that declares the effect. Once a `handle` block's body
500
+ finishes, its handlers are gone — code that runs afterwards (or a lazy
501
+ pipeline consumed afterwards, see the caveat below) no longer sees them.
502
+
503
+ ### Error handling
504
+
505
+ | Situation | Result |
506
+ |---|---|
507
+ | Effect invoked with no active handler for it | `UnhandledEffectError`: `` Unhandled effect: fetchUser\nNo handler registered for effect "fetchUser" `` |
508
+ | Handler throws (or its returned Promise rejects) | `EffectHandlerError`, naming the effect and wrapping the original error as `.cause` |
509
+ | Handler value isn't a function or `{ handle, deterministic }` | `TypeError`, thrown immediately when `handle { ... }` installs it, not at call time |
510
+ | A handler recursively invokes the same effect (directly, or indirectly through another effect) | `RecursiveEffectError`, naming the effect |
511
+ | Effect invoked entirely outside any `handle` block | Same as "no active handler" — `UnhandledEffectError` |
512
+
513
+ ### `parallel()` as an effect
514
+
515
+ `items |> parallel(n) |> map(fn)` is effect-backed. With no handler
516
+ installed, it behaves exactly as before (a bounded-concurrency worker
517
+ pool — existing programs are unaffected). Installing a `PARALLEL` handler
518
+ lets a different interpretation take over completely:
519
+
520
+ ```jojo
521
+ handle {
522
+ PARALLEL: async ({ items, fn, concurrency }) => {
523
+ // deterministic, strictly sequential — great for tests
524
+ results := []
525
+ for await (const item of items) results.push(await fn(item))
526
+ return results
527
+ }
528
+ } {
529
+ numbers |> parallel(8) |> map(process) |> toArrayAsync |> await
530
+ }
531
+ ```
532
+
533
+ A `PARALLEL` handler receives `{ items, fn, concurrency, meta }` and returns
534
+ an (async) iterable of results in order — the same contract the built-in
535
+ implementation follows. This lets production code use real concurrency,
536
+ tests force deterministic sequential execution, and a debugging handler
537
+ record scheduling information, all without changing the pipeline. See
538
+ [example/src/effects/parallel-effect.jojo](example/src/effects/parallel-effect.jojo).
539
+
540
+ ### Effects in the execution plan
541
+
542
+ `jojo graph`/`analyzePipelines` expose which pipeline nodes reference which
543
+ declared effects, and a best-effort static "is a handler for it declared
544
+ anywhere in this file?" signal:
545
+
546
+ ```jojo
547
+ effect fetchUser(id)
548
+ effect saveUser(user)
549
+
550
+ handle {
551
+ fetchUser: id => ({ id })
552
+ } {
553
+ users
554
+ |> map(user => fetchUser(user.id))
555
+ |> parallel(8)
556
+ |> map(saveUser)
557
+ }
558
+ ```
559
+
560
+ ```
561
+ SOURCE users
562
+ │
563
+ ▼
564
+ MAP user => fetchUser(user.id) [effects: fetchUser]
565
+ │
566
+ ▼
567
+ PARALLEL 8
568
+ │
569
+ ▼
570
+ MAP saveUser [effects: saveUser]
571
+ │
572
+ ▼
573
+ RESULT
574
+ ```
575
+
576
+ Programmatically, `analyzePipelines(source)` returns each pipeline's graph
577
+ with an `effects` array:
578
+
579
+ ```js
580
+ [
581
+ { name: "fetchUser", nodeId: "node-1", staticallyHandled: true },
582
+ { name: "saveUser", nodeId: "node-3", staticallyHandled: false }
583
+ ]
584
+ ```
585
+
586
+ `staticallyHandled` is a textual, best-effort signal (a `handle { name: ... }`
587
+ naming that effect was found somewhere in the file) — it cannot see handlers
588
+ installed conditionally or dynamically at runtime. For that, call
589
+ `describeHandlers()` from inside a running `handle { ... }` block: it
590
+ reports, per currently-handled effect, whether the installed handler
591
+ declared itself `deterministic`, and the declaring effect's `sideEffecting`/
592
+ `replayable` metadata (from `effect name(params)`'s options, or defaults —
593
+ `sideEffecting: true`, `replayable: false`).
594
+
595
+ ### Effects and checkpoint/resume
596
+
597
+ Consider:
598
+
599
+ ```jojo
600
+ effect fetchUser(id)
601
+
602
+ ids
603
+ |> mapAsync(fetchUser)
604
+ |> checkpoint("users-fetched")
605
+ |> toArrayAsync
606
+ ```
607
+
608
+ `checkpoint(name)` either streams items through once and durably saves them
609
+ all, or — if a checkpoint for `name` already completed — returns the saved
610
+ items **without ever touching the upstream iterable at all**. This gives
611
+ effects placed before a checkpoint a specific, non-magical guarantee:
612
+
613
+ - **Once a checkpoint has completed**, a later run does **not** re-invoke
614
+ the upstream effect(s) — the saved items are restored instead. This is
615
+ restoration from checkpoint state, not effect replay: the checkpoint
616
+ store never sees the individual `fetchUser` calls, only the pipeline
617
+ items that came out the other side.
618
+ - **If a checkpoint never completed** (crash, first run, ...), the upstream
619
+ effect(s) run again from scratch on the next attempt — there is no
620
+ per-item resume within an incomplete checkpoint.
621
+
622
+ **This is explicitly not exactly-once effect execution.** It is
623
+ "at-least-once until the checkpoint fully completes, then exactly zero
624
+ additional times after." A non-idempotent, non-replayable effect
625
+ (`replayable: false`, the default) must tolerate being invoked again on a
626
+ crash-and-retry before its checkpoint completes. Mark an effect
627
+ `{ replayable: true }` only if calling it again with the same arguments is
628
+ actually safe. See
629
+ [example/src/effects/checkpoint-resume.jojo](example/src/effects/checkpoint-resume.jojo)
630
+ and `test/effects-checkpoint.test.js` for both cases exercised end to end.
631
+
632
+ ### Record/replay
633
+
634
+ A handler map can be wrapped to record every call (arguments + result) to
635
+ an `EffectLog`, and later replayed from that log without running any real
636
+ code:
637
+
638
+ ```js
639
+ import { recordHandlers, replayHandlers, withHandlers } from "@panagos/jojoscript/runtime"
640
+ import { FileEffectLog } from "@panagos/jojoscript/runtime"
641
+
642
+ const log = new FileEffectLog("./fetchUser.log.json")
643
+
644
+ // Recording run: calls the real handler, and appends each call to the log.
645
+ await withHandlers(recordHandlers({ fetchUser: realFetchUser }, log), program)
646
+
647
+ // Replay run: never calls realFetchUser again, just replays recorded results.
648
+ await withHandlers(replayHandlers(["fetchUser"], log), program)
649
+ ```
650
+
651
+ `EffectLog` (`MemoryEffectLog`/`FileEffectLog`) is a small, storage-agnostic
652
+ interface (`record`/`replayNext`), mirroring `CheckpointStore` — an
653
+ extension point for a future backend, not a full production-grade
654
+ persistence system.
655
+
656
+ ### JavaScript interoperability
657
+
658
+ A handler is just a normal JavaScript function — sync or async — and can
659
+ call anything JavaScript can call:
660
+
661
+ ```jojo
662
+ effect fetchUser(id)
663
+
664
+ handle {
665
+ fetchUser: id => fetch(`/users/${id}`)
666
+ } {
667
+ ...
668
+ }
669
+ ```
670
+
671
+ ### Guarantees and limitations
672
+
673
+ - Handler dispatch is dynamic-scope-based (an `AsyncLocalStorage` stack),
674
+ the same mechanism `checkpoint`/`inspect`/metrics already use for
675
+ execution context. Because pipeline stages are lazy, a `handle { ... }`
676
+ block's handlers are only active while its body is *consumed* (e.g. via
677
+ `toArray`/`toArrayAsync`/`reduce`/...), not merely constructed — always
678
+ consume (or `await`) an effectful pipeline inside the `handle` block
679
+ itself.
680
+ - No delimited continuations: a handler cannot `resume` the computation
681
+ that invoked the effect, inspect intermediate state, or resume it more
682
+ than once.
683
+ - Recursion guard: a handler invoking the same effect it is currently
684
+ handling (directly or through another effect) is rejected with
685
+ `RecursiveEffectError` rather than hanging or overflowing the stack.
686
+ - Existing programs that never use `effect`/`handle` syntax, and existing
687
+ `parallel()` usage with no `PARALLEL` handler installed, are completely
688
+ unaffected — this is additive, opt-in syntax.
689
+
690
+
309
691
 
310
692
  JojoScript intentionally leaves ordinary JavaScript alone. You can use:
311
693
 
@@ -366,6 +748,12 @@ report, alongside one small focused module (and test) per feature. Every
366
748
  module is imported and invoked from `example/src/main.jojo`, so both
367
749
  `npm start` and `npm test` exercise the whole example.
368
750
 
751
+ [example/src/effects/](example/src/effects/) has six focused Effect Handlers
752
+ examples (each with a matching test under `example/test/`): basic effects,
753
+ running the same program with multiple handlers, deterministic testing,
754
+ `parallel()` as an effect, effect visibility in the execution plan, and the
755
+ checkpoint/resume interaction.
756
+
369
757
  ```bash
370
758
  cd example
371
759
  npm install
@@ -426,4 +814,16 @@ resulting `.vsix` locally.
426
814
  - **Multi-binding declarations (`a, b := 1, 2`) are single-line only** and
427
815
  always wrap the right-hand side in `[...]`; use `[a, b] := expr()` to
428
816
  destructure a single expression instead.
817
+ - **Effect Handlers are a simplified model, not full algebraic effects.**
818
+ No delimited continuations/`resume`; a handler cannot inspect or resume
819
+ the suspended computation, or resume it more than once. See the
820
+ "Effect Handlers" section for the full list of guarantees/limitations.
821
+ - **`handle { ... } { ... }` handlers are only active while their body is
822
+ actually consumed.** Constructing a lazy pipeline inside a `handle` block
823
+ without consuming it there (no `toArray`/`toArrayAsync`/`reduce`/...) means
824
+ effects pulled later, outside the block, won't see its handlers.
825
+ - **Only `PARALLEL` is a built-in effect today.** The other capabilities
826
+ mentioned as a long-term direction (`IO`, `HTTP`, `FILE`, `DATABASE`,
827
+ `SLEEP`, `TIME`, `RANDOM`, `CHECKPOINT`) are not implemented — user-defined
828
+ `effect` declarations cover the same need today.
429
829
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panagos/jojoscript",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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
@@ -1,19 +1,33 @@
1
- export {
2
- compile,
3
- compileFile,
4
- compileDirectory
5
- } from './compiler.js'
6
-
7
- export {
8
- transformPipelines,
9
- emitPipeline,
10
- findPipelineStdlibFunctions
11
- } from './pipeline-parser.js'
12
-
13
- export {
14
- transformMatchExpressions
15
- } from './match-parser.js'
16
-
17
- export {
18
- transformTypeAnnotations
19
- } from './type-annotations.js'
1
+ export {
2
+ compile,
3
+ compileFile,
4
+ compileDirectory
5
+ } from './compiler.js'
6
+
7
+ export {
8
+ transformPipelines,
9
+ emitPipeline,
10
+ findPipelineStdlibFunctions
11
+ } from './pipeline-parser.js'
12
+
13
+ export {
14
+ transformMatchExpressions
15
+ } from './match-parser.js'
16
+
17
+ export {
18
+ transformTypeAnnotations
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
 
@@ -112,9 +112,18 @@ function splitArguments(tokens) {
112
112
  return result
113
113
  }
114
114
 
115
+ function isPropertyAccessName(name) {
116
+ return /^\.[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(name)
117
+ }
118
+
115
119
  function parseStage(source, tokens) {
116
120
  if (!tokens.length) throw new SyntaxError('Empty pipeline stage')
117
121
 
122
+ const bareName = text(source, tokens).trim()
123
+ if (isPropertyAccessName(bareName)) {
124
+ return { name: bareName, args: [], propertyCall: false }
125
+ }
126
+
118
127
  if (tokens.every((token, index) =>
119
128
  index % 2 === 0
120
129
  ? token.type === 'identifier'
@@ -145,6 +154,14 @@ function parseStage(source, tokens) {
145
154
 
146
155
  const name = text(source, tokens.slice(0, openIndex)).trim()
147
156
 
157
+ if (isPropertyAccessName(name)) {
158
+ const args = splitArguments(
159
+ tokens.slice(openIndex + 1, closeIndex)
160
+ ).map(argument => text(source, argument).trim())
161
+
162
+ return { name, args, propertyCall: true }
163
+ }
164
+
148
165
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(name)) {
149
166
  throw new SyntaxError(`Invalid pipeline function: ${name}`)
150
167
  }
@@ -310,6 +327,25 @@ export function emitPipeline(pipeline, options = {}) {
310
327
  continue
311
328
  }
312
329
 
330
+ if (stage.name.startsWith('.')) {
331
+ const args = stage.args.slice()
332
+ const placeholderIndex = args.indexOf('_')
333
+ if (placeholderIndex !== -1) {
334
+ args[placeholderIndex] = expression
335
+ }
336
+
337
+ if (stage.propertyCall || args.length > 0) {
338
+ expression = `${expression}${stage.name}(${args.join(', ')})`
339
+ } else {
340
+ expression = `${expression}${stage.name}`
341
+ }
342
+
343
+ if (options.profile) {
344
+ expression = `traceNode(${expression}, ${metaLiteral(pipelineId, stage.nodeId, stage.name)})`
345
+ }
346
+ continue
347
+ }
348
+
313
349
  if (stage.name === 'retry' && stage.standaloneRetry) {
314
350
  const meta = metaLiteral(pipelineId, stage.nodeId, 'retry')
315
351
  expression = `(await retry(async () => (${expression}), ${stage.args[0]}, ${meta})())`
@@ -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