This guide is for continuing an in-progress refactor: converting each entry in
nodely.api.v0/engine-data from a plain data map into a type that implements
nodely.engine.protocols/Engine.
Three engines are already migrated — read them as worked examples before you start:
:sync.lazy→nodely.engine.lazy/LazyEngine— the simple case (no optional dependency).:core-async.lazy-scheduling→nodely.engine.core-async.lazy-scheduling-engine/CoreAsyncLazySchedulingEngine— the optional-dependency, channel-SUPPORTING case (the full pattern).:core-async.iterative-scheduling→nodely.engine.core-async.iterative-scheduling-engine/CoreAsyncIterativeSchedulingEngine— the optional-dependency, channel-LESS case (same facade pattern, but-eval-key-channelthrowsUnsupportedOperationException; see PITFALL 3b).
Do one engine at a time. Run the tests after each. Do not try to do several at once.
nodely.engine.protocols/Engine has six methods. Every migrated engine must
implement all six:
(-eval [engine env k opts]) ; -> resolved env
(-eval-key [engine env k opts]) ; -> value of k
(-eval-key-channel [engine env k opts]) ; -> channel yielding value of k
(-eval-key-channel-supported? [engine]) ; -> true/false
(-enable-deref [engine]) ; -> a delay (see below)
(-prepare-opts [engine opts]) ; -> opts to pass to -eval*The dispatch in v0.clj already routes any entry that has
::protocol-engine? true. You do not need to change protocols.clj.
Look at the engine's existing entry in engine-data. Does it have an
::enable-deref key?
-
NO
::enable-deref(only:sync.lazytoday) → the engine has no optional dependency. Put thedeftypedirectly in the engine's implementation namespace, and make-enable-derefreturn(delay nil). CopyLazyEngine. -
HAS
::enable-deref(every other engine) → the engine depends on an optional (:scope "provided") library (core.async, manifold, promesa, virtual-futures). This is the case with the pitfalls. Use the facade-namespace pattern below. CopyCoreAsyncLazySchedulingEngine.
api/v0.clj is always loaded, even when the optional library is absent. If the
deftype lived in a namespace that (:require [clojure.core.async ...]) (or
manifold/promesa), then v0.clj referencing it would fail to load the moment
the library is missing. That defeats nodely's "works without the optional deps"
contract.
DO: create a new, small facade namespace whose only :require is
nodely.engine.protocols. It must NOT require the optional library, and must
NOT require any namespace that transitively requires it. Load the real
implementation lazily, inside the method bodies. See
lazy_scheduling_engine.clj for the exact shape:
(ns nodely.engine.<family>.<engine>-engine
(:require [nodely.engine.protocols :as engine.protocols]))
(def ^:private impl-ns 'nodely.engine.<family>.<engine>) ; the real impl
(def enable-deref
(delay
(try (require impl-ns) nil
(catch Exception e
{:msg "Could not locate <lib> on classpath." :cause e}))))
(defn- impl [fn-name]
(requiring-resolve (symbol (name impl-ns) (name fn-name))))
(deftype <Engine> []
engine.protocols/Engine
(-eval [_ env k opts] ((impl 'eval) env k opts))
(-eval-key [_ env k opts] ((impl 'eval-key) env k opts))
;; -eval-key-channel: pick ONE of the two shapes below -- see PITFALL 3b.
;; channel-SUPPORTING engine (impl HAS an eval-key-channel fn):
(-eval-key-channel [_ env k opts] ((impl 'eval-key-channel) env k opts))
;; channel-LESS engine (impl has NO eval-key-channel fn):
;; (-eval-key-channel [_ _ _ _]
;; (throw (UnsupportedOperationException. "Engine :the-engine does not support eval-key-channel.")))
(-eval-key-channel-supported? [_] <true-or-false>)
(-enable-deref [_] enable-deref)
(-prepare-opts [_ opts] <see PITFALL 3>))-enable-deref answers "can this engine run on this classpath?". Because you
must construct the instance before you can call a method on it, and because the
facade constructs without the optional library, the correct order is:
- construct the engine instance,
- deref
-enable-deref; if it returns a failure map, throw, - only then call
-eval/-eval-key/-eval-key-channel.
The dispatch already does this through the protocol-engine helper in v0.clj.
You do not need to re-derive it — just make sure your -enable-deref returns a
delay that attempts (require impl-ns) and reports the failure.
Do NOT try to check availability before constructing, and do NOT put the availability check anywhere that requires loading the optional library first.
Look at the engine's current ::opts-fn and make -prepare-opts do the same
thing. Do not blindly copy LazyEngine's nil.
::opts-fn identity→(-prepare-opts [_ opts] opts)::opts-fn (constantly nil)→(-prepare-opts [_ _opts] nil)::opts-fn #(assoc % ::applicative/context ...)→ reproduce thatassocinside-prepare-opts(and note the(resolve '...context)runs lazily, which is fine because-enable-derefhas already confirmed the library is present by the time-prepare-optsis called).
Classify first: does the engine's data-map entry have ::eval-key-channel true? Equivalently, does its impl namespace define an eval-key-channel fn?
-
YES (channel-supporting) →
-eval-key-channel-supported?returnstrueand-eval-key-channeldelegates:((impl 'eval-key-channel) env k opts). Keep::eval-key-channel truein the v0 entry. -
NO (channel-less) — e.g.
:core-async.iterative-scheduling,:async.manifold,:applicative.promesa→-eval-key-channel-supported?returnsfalseand-eval-key-channelmust(throw (UnsupportedOperationException. "Engine :the-engine does not support eval-key-channel.")). Do NOT copy the delegating body:(impl 'eval-key-channel)resolves tonilfor a channel-less impl, so calling it throws a bare, uninformativeNullPointerException("cannot invoke nil"). That NPE is what the old data-map dispatch did by accident; the migration is the chance to replace it with an intentionalUnsupportedOperationException. Omit::eval-key-channelfrom the v0 entry entirely (do not set itfalse; the key's presence is what the tests read viachannel-interface).Worked example:
iterative_scheduling_engine.cljis the channel-less core.async engine — read it alongsidelazy_scheduling_engine.clj(the channel-supporting one) to see the two shapes side by side.
:requirethe new facade namespace (keep the require list alphabetical, orlein clean-nswill complain).- Replace the engine's data-map entry with:
:the-engine {::protocol-engine? true ::instance-constructor <facade-ns>/-><Engine> ::eval-key-channel <true-or-omit>} - Do not delete the shared failure delays.
core-async-failure is used by :applicative.core-async and the
>channel-leaf macro (it was also used by :core-async.iterative-scheduling
until that engine was migrated). The other *-failure
delays are likewise shared. Migrating one engine does not free its delay. Each
migrated engine gets its own enable-deref in its facade namespace; leave
the v0.clj delays alone until every engine that uses one is migrated.
The dispatch functions already have the right shape. Do not add a nested let.
The instance is bound once, guarded by when:
(let [engine-data (engine-data engine-name)
protocol-engine? (::protocol-engine? engine-data)
engine (when protocol-engine? (protocol-engine engine-name engine-data))]
(if protocol-engine?
(engine.protocols/eval engine env k (engine.protocols/-prepare-opts engine opts))
(let [efn (engine-fn engine-name 'eval)]
(if-let [opts ((::opts-fn engine-data) opts)]
(efn env k opts)
(efn env k)))))If you already migrated an engine, these three functions need NO further change — they are generic.
test/nodely/api_test.clj simulates "library missing" by bombing require and
resetting a delay. A migrated engine no longer consults the v0.clj
*-failure delay — it consults its own enable-deref in its facade
namespace. So for the engine you migrate:
- point the
testing-require-delayblock's bombed namespace at the engine's real implementation namespace (the one itsenable-derefrequires), and - point the reset delay at
<facade-ns>/enable-deref.
The reset helpers (ensure-unrealized-delay and the end-of-block reload) already
reload the delay's own namespace via (symbol (namespace sym)), so passing a
facade-namespace delay works without further change. Keep the assertion that the
engine throws its "Could not locate ..." message.
:applicative.promesa, :applicative.core-async, and
:applicative.virtual-future all share the implementation namespace
nodely.engine.applicative, which itself requires clojure.core.async and
injects a per-engine "context" resolved from an optional namespace. That means
the facade-must-not-transitively-require-the-optional-dep rule is harder to
satisfy, and -prepare-opts must reproduce the context injection. Do the
standalone engines first (:core-async.iterative-scheduling, :async.manifold,
:async.virtual-futures). Ask a human before attempting the applicative family.
Run these after each engine. Do not skip.
- Parse every file you touched:
bb -e '(require (quote [rewrite-clj.zip :as z])) (z/of-file "PATH")' - Format / namespaces:
lein formatandlein clean-ns(both dry) — must report nothing to change. - Tests:
lein test. Expect0 failures, 0 errors.- The one test in
nodely.engine.manifold-testthat compares timing is flaky (it allows only an 8ms tolerance on a ~2-second measurement). If it — and only it — fails on timing, just runlein testagain. Any other failure is a real regression.
- The one test in
- Re-run the touched namespaces a couple of times
(
lein test nodely.api-test nodely.profile-test) to confirm the test reset logic is stable.
Do not commit. Leave commits to a human reviewer.