@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 +403 -3
- package/package.json +1 -1
- package/src/compiler.js +30 -0
- package/src/effect-parser.js +223 -0
- package/src/index.js +33 -19
- package/src/pipeline-ir.js +63 -6
- package/src/pipeline-parser.js +36 -0
- package/src/runtime/collections.js +30 -1
- package/src/runtime/effect-log.js +90 -0
- package/src/runtime/effects.js +263 -0
- package/src/runtime/index.js +19 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# JojoScript 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,
|
|
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
|
-
##
|
|
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
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'
|
package/src/pipeline-ir.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/pipeline-parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/runtime/index.js
CHANGED
|
@@ -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
|
|