Skip to content

Add Ema.Dynamic.currentValue for out-of-band reads#177

Merged
srid merged 2 commits intomasterfrom
feat/dynamic-current-value
Apr 24, 2026
Merged

Add Ema.Dynamic.currentValue for out-of-band reads#177
srid merged 2 commits intomasterfrom
feat/dynamic-current-value

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 23, 2026

Dynamic is push-only, but some consumers need pull. The updater shape (a -> m ()) -> m () is perfect for a render loop that reacts to each new value, but a poor fit for a synchronous request handler — e.g. an HTTP server running alongside runSiteWith that wants to snapshot the current model per request. Before this PR, bridging push to pull meant hand-rolling an IORef tee outside Ema, as srid/emanote#649 did in its tapModel. Moving the primitive into Ema lets downstream apps stop re-deriving the same 8-line pattern.

currentValue tees a Dynamic: it returns (reader, wrapped) where reader :: IO a yields the latest published value (or the initial before any update), and wrapped :: Dynamic m a is a pass-through that must be used in place of the input. The wrapped updater calls the original's updater and additionally stores each value into the IORef backing the reader. No second producer thread, no FS-watcher duplication — there's still one updater running, we just intercept the send callback.

Non-breaking. The Dynamic newtype is unchanged; currentValue is purely additive. Callers that don't need pull access ignore the new export.

Try it locally

nix build github:srid/ema/feat/dynamic-current-value

Or see it in action once downstream lands: srid/emanote#649

Dynamic is push-only: its updater takes a send callback and runs
forever. That's a poor fit for consumers that need synchronous pull
access to the latest value — e.g. an HTTP handler running alongside
runSiteWith that wants to read the current model per request.

currentValue tees the Dynamic: it returns an IO reader that yields
the latest published value, plus a wrapped Dynamic that must be used
in place of the input. The wrapped Dynamic's updater calls the
original's updater and additionally stores each value into the IORef
backing the reader. No second producer thread, no FS watcher
duplication.

Motivated by Emanote's MCP server (srid/emanote#645), which needs to
expose the notebook model as read-only resources without driving
Ema's render loop.
@srid srid force-pushed the feat/dynamic-current-value branch from c4124e5 to 3fcae1f Compare April 23, 2026 22:47
@srid srid force-pushed the feat/dynamic-current-value branch from 1ec1238 to 270518c Compare April 23, 2026 22:52
@srid srid merged commit 134310a into master Apr 24, 2026
2 checks passed
srid added a commit that referenced this pull request Apr 24, 2026
**`runSiteWith` now composes out of two steps** — `siteInput` (build the
`Dynamic`) and `runSiteWithInput` (consume it). The same class of
problems that motivated `currentValue` (#177) — teeing the `Dynamic` for
an out-of-band consumer — needs a seam between those two steps. Before
this PR, `runSiteWith` sealed them together; the only escape was
plumbing a callback through the user's `SiteArg`, which either conflated
config with transport wiring or (worse) bypassed the user's `siteInput`
entirely.

_The user's `siteInput` still runs._ Callers that need the tee just
invoke `siteInput` themselves, compose (`currentValue`, `<*>`,
whatever), and pass the result to `runSiteWithInput`. Callers that don't
need it keep using `runSiteWith` exactly as before — its body is now a
two-liner:

```haskell
runSiteWith cfg arg = flip runLoggerLoggingT (getLogger …) $ do
  dyn <- siteInput @r (CLI.action cli) arg
  runSiteWithInput cfg dyn
```

`runSiteWithInput` is polymorphic in `m` (`MonadUnliftIO m,
MonadLoggerIO m, MonadFail m`), which also lets the live-server race
stay in one logger context — `UnliftIO.Async.race_` replaces
`Control.Concurrent.Async.race_`, and both branches run in `m` directly.
Net: the existing `liftIO $ race_` / `runLoggingT logger` scaffolding
collapses, and `async` drops out of the direct deps (it's still pulled
in transitively via `unliftio`).

> Fully additive. `runSite`, `runSite_`, `runSiteWith` are unchanged in
signature and behaviour. `runSiteWithInput` is a new export.

Motivated by
[srid/emanote#649](srid/emanote#649 MCP
server: with this PR, emanote drops its `tapModel` helper, the
`_emanoteConfigOnLiveModel` callback field, and the
publish-through-config plumbing entirely — MCP and Ema compose via
`race_` at the call site.

### Try it locally

```sh
nix build github:srid/ema/feat/run-site-with-input
```
@srid srid mentioned this pull request Apr 24, 2026
2 tasks
srid added a commit that referenced this pull request Apr 24, 2026
## Summary
- Adds a `test` test-suite stanza to `ema.cabal` so feature PRs can land
unit tests alongside code.
- Seeds it with `Ema.DynamicSpec` exercising `Ema.Dynamic.currentValue`
(#177): initial value before any update, and latest value after the
wrapped updater runs.
- Uses plain `hspec` rather than `hspec-discover` — the nix-provided
Haskell env does not expose the `hspec-discover` executable, so specs
are wired up manually in `test/Spec.hs`.

Closes #178.

## Test plan
- [x] `cabal test --enable-tests ema:test:test` — 2 of 2 pass
- [x] `cabal-fmt` and `fourmolu` pre-commit hooks pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant