Leveling Up CIDER’s ClojureScript Support

Continuing the series on the notable changes in CIDER 2.0, let’s talk about ClojureScript - forever the trickier sibling in the CIDER family.

I’ll start with a confession I’ve made before: I rarely use ClojureScript myself, which is a big part of why its support in CIDER has historically lagged behind Clojure’s. Every “State of CIDER” survey reminds me of this, usually in the comments section, occasionally in all caps. So in the 2.0 cycle I decided to stop feeling vaguely guilty about it and actually do something - across every layer of the stack: CIDER itself, cider-nrepl, and Piggieback.

First, a strategic decision

The most important ClojureScript change in CIDER 2.0 isn’t a feature - it’s a decision about what not to build. Some of CIDER’s most powerful tools (the debugger, enlighten, tracing, profiling) are deeply tied to JVM runtime introspection, and porting them to ClojureScript would be a massive effort with a poor cost/benefit ratio. Rather than keeping them in eternal “maybe someday” limbo, we’ve explicitly scoped them as Clojure-only and focused the actual work on the things cljs users hit every day: evaluation, testing, error reporting, and clear behavior everywhere else.

That last part matters more than it sounds. Historically, invoking a JVM-only command in a ClojureScript REPL would fail in some confusing way - a cryptic error, a JVM-flavored result, or silence. Now the ops themselves report a clojure-only status, and CIDER tells you plainly that the command isn’t supported for ClojureScript. Knowing what a tool won’t do is half of trusting it.

What actually got better

  • The regular test commands (cider-test-run-ns-tests and friends) now work in ClojureScript REPLs, asynchronous cljs.test/async tests included. Previously CIDER just refused, and you were stuck evaluating (run-tests) by hand like an animal.
  • Expanding your own macros (e.g. ones brought in via :refer-macros) used to silently echo the form back unexpanded - a bug filed all the way back in 2017. The compiler environment is now threaded to the analyzer properly, and it just works.
  • cider-nrepl now resolves the ClojureScript compiler environment through a provider chain, with a dedicated shadow-cljs provider - so the static-analysis ops keep working in a shadow REPL that never loads Piggieback.
  • The new cider-tap viewer works with ClojureScript too: a runtime helper buffers tapped values and the JVM side streams them to Emacs. (Tapped cljs values aren’t inspectable - they live in the JS runtime - but you see them as they happen.)
  • ClojureScript stack frames now render their ns/fn properly instead of degrading to nil/nil, and unqualified core vars resolve against cljs.core rather than falling back to clojure.core (which quietly broke things like indentation metadata).
  • A recent ClojureScript on the classpath (whose Closure compiler wants JDK 21+) no longer crashes cider-nrepl at startup on an older JDK - you get a Clojure-only session instead of no session.
  • Piggieback itself got a round of bug fixes in the 0.6.x/0.7.0 releases - it’s easy to forget it exists (which is rather the point of it), but it powers most cljs REPLs CIDER talks to.

The documentation kept pace too: the new full-stack Clojure + ClojureScript guide covers the two-REPLs-one-project setup that trips up nearly everyone, and the ClojureScript docs got a general refresh.

An unexpected assist

Fun aside: this is the area where AI coding agents helped me the most during the 2.0 cycle. My ClojureScript experience is limited, but between the excellent bug reports from the community and the ability to quickly prototype and test fixes against real shadow-cljs and figwheel setups, problems that had been “someone who knows cljs should look at this someday” for years finally got fixed. Make of that what you will.

What’s next

I keep pondering some form of “native” shadow-cljs support, given that shadow-cljs is what most ClojureScript users actually run these days. That’s still very much in the hammock phase, so don’t hold me to it - but the direction is clear: fewer moving parts, clearer errors, and honesty about what’s supported.

If you’re a ClojureScript user, I’d genuinely love to hear how 2.0 feels in your daily work - the feedback loop is what keeps this improving. Keep hacking!

Articles in the Series

Permalink

Eyre: Gathering System Facts

Crispin Wellington

Back in medieval England, an eyre was a travelling court. Royal justices would ride out to a county, set up, and go through everything; crimes, taxes, who owned what, who owed what. Before they could rule on anything they had to know the full state of the place. So the first job was always the same. Count it all up.

Most config tools start the same way. Before they touch anything, they probe the system to check what&aposs already there. This is fact gathering. The tool looks at the machine, builds a picture of its current state, then decides what to do next.

Puppet has Facter, Chef has Ohai, Ansible has its setup module. They all have the same job. To profile the machine (OS, memory, network, filesystem) and hand back the results as data you can use. You can&apost manage a system well if you don&apost know what it looks like right now.

As part of cleaning up and modernising Spire, I&aposm putting out a new small library: Eyre. It gathers system facts through a shell. Spire will end up using it for it&aposs facts.

You give Eyre a function that runs a shell script and hands back the result as {:exit exit-code :out stdout :err stderr}. That&aposs it. Because you supply the executor, Eyre itself has zero dependencies. Whatever it needs is injected.

Put the following in a file gather.clj:

(ns gather
  (:require [babashka.process :as process]
            [clojure.pprint :as pprint]
            [eyre.core :as eyre]))

(defn make-exec [shell]
  (fn [script]
    (process/shell {:in script
                    :out :string
                    :err :string}
                   shell)))

(pprint/pprint
  (eyre/gather (make-exec "bash")))

then run it with babashka:

$ bb -Sdeps &apos{:deps {io.epiccastle/eyre {:mvn/version "0.1.1"}}}&apos gather.clj
{:shell
 {:type :bash,
  :version "5.3.15(1)-release",
  :shell "/bin/bash",
  :canonical-path "/usr/bin/bash"},
 :os
 {:family :linux,
  :kernel
  ...

You will see it dump all the facts it could find running as your user on a local bash shell.

What keys do we have?

(keys (eyre/gather (make-exec "bash"))
;;=> (:shell :os :hardware :users :filesystem :network :paths)

Lets just pull out the :shell portion of the response:

(:shell (eyre/gather (make-exec "bash"))
;;=>
{:type :bash,
 :version "5.3.15(1)-release",
 :shell "/usr/bin/bash",
 :login-shell "/bin/bash",
 :canonical-path "/usr/bin/bash"}

I can try launching it through other shells by changing "bash" to "zsh", "fish" or another shell and it continues to work.

(:shell (eyre/gather (make-exec "zsh"))
;;=>
{:type :zsh,
 :version "5.9.2",
 :shell "/usr/bin/zsh",
 :login-shell "/bin/bash",
 :canonical-path "/usr/bin/bash"}

(:shell (eyre/gather (make-exec "fish"))
;;=>
{:type :fish,
 :version "4.8.1",
 :shell "/usr/bin/fish",
 :login-shell "/bin/bash",
 :canonical-path "/usr/bin/bash"}

Here you can see the :login-shell continues to show the parent shell, while :shell shows the path of the shell process that you are running inside.

All decision on what to run in the executor is based on the :type of the shell. Eyre supports bash, zsh, sh, dash, ksh, busybox, fish, nushell, PowerShell and even cmd.exe. It can probe Linux, FreeBSD, NetBSD, macOS and Windows hosts.

Over SSH

Since Eyre just needs a function that runs a command and returns {:exit :err :out}, you&aposre not stuck running it locally. Plug in an executor that runs over SSH, and now you&aposre gathering facts from a remote machine instead.

Here&aposs what that looks like using clojuressh:

(ns gatherssh
  (:require [clojure.pprint :as pprint]
            [clojuressh.core :as ssh]
            [clojuressh.session :as session]
            [eyre.core :as eyre]))

(let [session (ssh/ssh "remotehost.com" {:username "remote-username"})
      exec (fn [script]
             @(ssh/exec session script {:out :string :err :string}))
      facts (eyre/gather exec)]
  (session/disconnect session)
  (pprint/pprint (:shell facts)))
;; =>
{:type :bash,
 :version "4.3.48(1)-release",
 :shell "/bin/bash",
 :login-shell "/bin/bash",
 :canonical-path "/bin/bash"}

Run:

bb -Sdeps &apos{:deps {io.epiccastle/eyre {:mvn/version "0.1.1"} io.epiccastle/clojuressh {:mvn/version "1.0.0"}}}&apos gatherssh.clj

AI in Development

LLM assisted coding provided two great benefits during development. The first was script translation. They are very competent at translating software from one language to another and certainly I do not know the idiosyncrasies of every shell.

The second was help setting up a significant test platform. Helping to write Packer scripts to build VMs, or Docker scripts to build containers, there was a lot of work here. Without AI doing a lot of that drudgery the library would not be tested across so many operating systems and shells.

Improvements

Running over the network brings a problem you don&apost get locally: latency. Every network shell call has a delay, and if you split fact gathering into lots of small calls, those delays stack up.

Right now, some of the probe scripts are joined together and run as one, so a slow connection doesn&apost pay round trip cost over and over. But there&aposs more to do. More scripts could be merged the same way. And beyond that, the gathering itself could be smarter. It could pull only the data you actually need instead of everything. These improvements will be left for later versions.

You can find the code here and the output documentation here.

I hope you find some uses for this tool.

Permalink

LangGraph isn't cheaper than LangChain — unless you opt out of its defaults

LangGraph isn't cheaper than LangChain — unless you opt out of its defaults

Cost-audit series, episode 4. This series began with an AI agent that burned 136M tokens overnight →.

When LangChain deprecated ConversationBufferMemory (the subject of episode 1 in this series), the official migration path was LangGraph. The pitch: explicit state management, you control exactly what flows where. More expressive, more controllable.

It is — but only if you reach for the controls. The default state model in LangGraph has the same unbounded-growth problem as the memory it replaced. Teams migrating to escape ConversationBufferMemory's cost curve often land on an identical curve, with new graph complexity on top.

This audit shows exactly where the default grows, what it costs, and what opt-outs exist.

The default: MessagesState + add_messages

The quickstart in LangGraph's own docs uses this pattern:

from langgraph.graph import StateGraph, MessagesState

def my_node(state: MessagesState):
    messages = state["messages"]
    response = llm.invoke(messages)   # sends ALL messages to the LLM
    return {"messages": [response]}

graph = StateGraph(MessagesState)
graph.add_node("agent", my_node)

MessagesState is a TypedDict with a single key, messages, backed by the add_messages reducer. Here's what that reducer does:

# langgraph/graph/message.py — add_messages (def at line 18; merge loop below)
def add_messages(left: Messages, right: Messages) -> Messages:
    # ... (coerces left/right to lists of BaseMessage) ...
    left_idx_by_id = {m.id: i for i, m in enumerate(left)}
    merged = left.copy()
    ids_to_remove = set()
    for m in right:
        if (existing_idx := left_idx_by_id.get(m.id)) is not None:
            if isinstance(m, RemoveMessage):
                ids_to_remove.add(m.id)
            else:
                merged[existing_idx] = m      # same id → update in place
        else:
            merged.append(m)                  # new id → APPEND (the list grows)
    merged = [m for m in merged if m.id not in ids_to_remove]
    return merged

Source: langgraph/graph/message.py

This is not a summarizer, not a window, not a trimmer. It is an append-only list. Every message ever added to state stays in state — and every node that reads state["messages"] sees the full list.

This is ConversationBufferMemory with a graph wrapper.

The cost math

Assume a conversational agent: 10 turns, 150 tokens per user message, 200 tokens per assistant reply (modest — a short answer each time).

After 10 turns, state["messages"] contains 20 messages = (10 × 150) + (10 × 200) = 3,500 tokens of accumulated history.

For the 11th call, the node sends all 3,500 tokens of prior history as context, then generates a new reply. Each further turn adds another 350 tokens (150 user + 200 assistant), so the 12th call sends 3,850, the 13th 4,200, and so on.

Total input tokens for a 20-turn conversation:

Turn Messages in state Input tokens (messages + system)
1 0 prior 150 + 400 (system)
5 4 prior turns 1,550 + 400
10 9 prior turns 3,300 + 400
15 14 prior turns 5,050 + 400
20 19 prior turns 6,800 + 400
Total ~77,500 tokens input

(Each call = 400 system + 150 current user + (turn−1) × 350 accumulated history.)

A naive estimate (flat 550 tokens/call × 20 calls) = 11,000 tokens.

Actual with add_messages default = ~77,500 tokens. 7× over.

With claude-haiku-4-5 ($0.80/M input, $4/M output) for a chatbot doing 500 conversations/day:

  • Naive estimate: 11,000 × 500 × 30 × $0.80/M = $132/month
  • Actual: 77,500 × 500 × 30 × $0.80/M = $930/month

That's $798/month of silent overspend on input tokens alone, just from the default accumulation — before you add nodes, tools, or memory.

Multiplier 1: multi-node graphs (each node pays the full state)

LangGraph's value over a simple chat loop is composing multiple nodes — a router, a tool-caller, a summarizer, a responder. Each node that reads state["messages"] pays the full token cost of the accumulated message list.

graph = StateGraph(MessagesState)
graph.add_node("router", route_node)      # reads state["messages"]
graph.add_node("tool_caller", tool_node)  # reads state["messages"]
graph.add_node("responder", respond_node) # reads state["messages"]

For a 3-node graph where each node reads messages, a single user turn that passes through all three nodes costs the message-list tokens. After 10 turns with 3,500 accumulated tokens, one user message costs: 3 × 3,500 = 10,500 tokens just for message history, before any node-specific prompts.

Multiplier 2: interrupt_before / interrupt_after (human-in-the-loop)

LangGraph's human-in-the-loop feature pauses graph execution at a node boundary. When the graph resumes, it deserializes the full checkpointed state and re-injects it into the next node:

# langgraph/pregel/__init__.py — Pregel.astream (v0.2.60), the entrypoint
# that drives interruptible execution. Verbatim signature:
async def astream(
    self,
    input: Union[dict[str, Any], Any],
    config: Optional[RunnableConfig] = None,
    *,
    stream_mode: Optional[Union[StreamMode, list[StreamMode]]] = None,
    output_keys: Optional[Union[str, Sequence[str]]] = None,
    interrupt_before: Optional[Union[All, Sequence[str]]] = None,
    interrupt_after: Optional[Union[All, Sequence[str]]] = None,
    debug: Optional[bool] = None,
    subgraphs: bool = False,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
    ...

Source: langgraph/pregel/__init__.py — search the file for async def astream (defined ~line 1683; interrupt_before/interrupt_after are the pause controls). When a run resumes after an interrupt, Pregel reloads the pending state from the checkpointer (the aget_tuple/aget_state path returns the full checkpoint blob — every message included) before continuing at the next node.

The cost: full state deserialization on every resume. If a workflow interrupts 3 times before completion (a common approval flow), and the state has 5,000 tokens of messages, the resumption overhead alone is 3 × 5,000 = 15,000 extra tokens — paid every time, even if the approval is just a "yes."

Multiplier 3: parallel fan-out (Send API)

LangGraph's Send API dispatches parallel subgraph invocations, each receiving a copy of state:

from langgraph.types import Send

def fanout_node(state: MessagesState):
    return [
        Send("worker_a", {"messages": state["messages"], "task": "summarize"}),
        Send("worker_b", {"messages": state["messages"], "task": "critique"}),
        Send("worker_c", {"messages": state["messages"], "task": "expand"}),
    ]

Source: langgraph/types.py

Each Send carries the full state["messages"] to the worker node. With 3 workers and 5,000 tokens of history: 15,000 tokens dispatched in the fan-out alone. If those workers themselves call an LLM, each call pays the full 5,000-token history again. Compare to the CrewAI quadratic problem from episode 3 — this is the same failure mode, different API.

The opt-outs (LangGraph actually provides them)

Unlike ConversationBufferMemory (which had no good trim story), LangGraph ships built-in tools to fix this. Teams just don't use them by default.

Trim messages before every LLM call

from langchain_core.messages import trim_messages

def my_node(state: MessagesState):
    trimmed = trim_messages(
        state["messages"],
        max_tokens=2000,          # hard cap
        strategy="last",          # keep the most recent
        token_counter=llm,        # use the model's tokenizer
        include_system=True,      # always keep the system message
        allow_partial=False,
    )
    response = llm.invoke(trimmed)   # sends trimmed history, not full list
    return {"messages": [response]}

Source: langchain_core/messages/utils.py

This keeps the full history in state (for checkpointing, human inspection) while capping what the LLM actually sees. Applying a 2,000-token cap on a 20-turn conversation reduces input tokens from ~77,500 to ~40,000 (2,000 tokens × 20 calls). ~48% cost reduction, one line change.

Pass only what the node needs

Instead of giving every node the full state["messages"], scope what each node receives:

class MyState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    last_tool_result: str        # structured, compact
    task_description: str        # set once, doesn't grow

def tool_caller_node(state: MyState):
    # This node only needs the task + last result — not the full conversation
    prompt = f"Task: {state['task_description']}\nContext: {state['last_tool_result']}"
    response = llm.invoke(prompt)
    return {"last_tool_result": response.content}

The tool_caller_node never touches state["messages"] — it pays zero for message accumulation. Only the nodes that genuinely need conversational context receive it.

Summarize periodically (the right way)

def maybe_summarize(state: MessagesState):
    messages = state["messages"]
    if len(messages) > 20:                # threshold: tune to your cost tolerance
        summary = llm.invoke([
            SystemMessage("Summarize this conversation in 3 sentences."),
            *messages,
        ])
        return {
            "messages": [
                SystemMessage(f"Conversation summary: {summary.content}"),
                messages[-2],    # keep the last human message
                messages[-1],    # keep the last assistant message
            ]
        }
    return {}   # no change needed

This collapses the accumulated history into a single system message at the summarization trigger. After summarization, the effective context is ~200 tokens (summary + last exchange) instead of 3,500+. Insert maybe_summarize as an always-on node before expensive LLM calls.

Measuring your own graph

LangGraph's built-in tracing (via LangSmith) shows per-node token usage, but it's behind a paid tier for production volumes. For a free alternative that works with any JSONL export:

npm install -g @wartzar-bee/tokenscope
npx @wartzar-bee/tokenscope langgraph-session.jsonl

Output shows the session total, how much of each call is re-sent accumulated context versus new work, and where the growth is steepest — the same view that surfaced the 136M-token burn in episode 1.

Summary

LangGraph's MessagesState + add_messages default is ConversationBufferMemory under a new name. The graph model gives you explicit controls that the old memory API lacked — but the controls are opt-in. Without trim_messages, selective state reads, or periodic summarization, migration from LangChain to LangGraph buys you graph expressiveness at the same (or higher, for multi-node graphs) token cost.

Pattern Cost impact Fix
MessagesState default 7× over naive estimate at 20 turns trim_messages before every LLM call
Multi-node graph, all nodes read messages N× multiplier (N = node count) Scope state: only pass what each node needs
interrupt/resume Full state re-injected on every resume Summarize before checkpointing at long sessions
Send fan-out Parallel full-state copies Pass minimal substate to each worker

The migration from LangChain to LangGraph is worth it — but only once you understand and explicitly opt out of these defaults. Otherwise you're paying for the complexity without the savings.

wartzar-bee builds tools for operating cost-efficient autonomous agents. tokenscope is free and open-source. Follow on dev.to →

Permalink

Sharpening CIDER’s Debugging Tools

The series on the notable changes in CIDER 2.0 rolls on. This time: the “what is my code actually doing?” toolbox - the debugger, tracing, enlighten, and the new tap viewer. This was the part of the release I enjoyed working on the most, and the part that needed the most love.

The debugger, dusted off

CIDER’s interactive debugger is one of its most impressive features and, paradoxically, one of its least reliable ones. Instrumenting arbitrary Clojure code is hard - the debugger rewrites your forms to capture locals at every step, and the corner cases are endless. Over the 2.0 cycle (and the 0.62.x releases of cider-nrepl) a whole family of long-standing instrumentation bugs got fixed:

  • Record literals embedded in code survive instrumentation instead of being quietly downgraded to plain maps - which used to break protocol dispatch in anything that compiled routes or components into records (compojure users know the pain).
  • defrecord/deftype inline methods no longer blow up with the infamous Unable to resolve symbol: STATE__ error - the instrumenter now sensibly skips the method bodies, which compile to real JVM methods that can’t capture debugger state. #dbg on a bare collection literal triggered the same error; fixed too. And heavily destructured argument lists used to crash instrumentation in their own special way - not anymore.
  • A form too large to instrument (yes, that’s a JVM limitation - Method code too large!) now degrades gracefully: CIDER retries without local capture and tells you what happened, instead of surfacing a raw compiler error.
  • Quitting a debug session uses nREPL’s interrupt machinery instead of the deprecated Thread.stop, so it keeps working on modern JDKs where Thread.stop is simply gone.

The UX got attention too. Quitting the debugger with q finally restores point to where you started the session - a request filed in 2016 - instead of stranding you at the last breakpoint. The force-step-out key works again. And all the debugger’s single-key commands are now proper named commands with a transient menu (?) listing them, so you’re never stuck trying to remember whether locals was l or L.

Tracing grew a home

clojure.tools.trace-style tracing has been in CIDER forever, but the output was always interleaved into the REPL, where it fought with your actual work. CIDER 2.0 gives traces a dedicated, live-streaming *cider-trace* buffer:

The *cider-trace* buffer showing a nested call tree with return values

Calls fold and unfold (TAB, or F/U for everything at once), n/p move between calls, and . jumps to a function’s definition. cider-list-traced answers the eternal “wait, what did I even trace?”, and cider-untrace-all cleans the slate.

Enlighten, back from the dead

Enlighten - the mode that displays the values of locals inline as your code runs - has been in “experimental” limbo since 2016. It finally got a proper overhaul: a real test suite, fixes for the same record/deftype instrumentation bugs as the debugger (they share machinery), and - importantly - manners. You can now enlighten a single form with cider-enlighten-defun-at-point instead of flipping a global mode, and cider-enlighten-stop turns everything off at once, rather than making you re-evaluate every function in penance.

Every local and every intermediate result, right there in the buffer:

Enlighten showing argument and return values inline in the source

Tap into your programs

New in 2.0: cider-tap, a buffer that streams every value sent to tap> and lets you crack any of them open in the inspector with RET. tap> has quietly become the Clojure community’s favorite debugging primitive, and now you don’t need an external tool like Portal or Reveal for the basic workflow - though those remain great if you want more. (ClojureScript taps stream too; they’re just not inspectable, since the values live in the JS runtime.)

It’s println debugging, minus the println guilt.

The connective tissue

A few related quality-of-life items round out the picture: stack frames for top-level anonymous functions jump to their actual source instead of clojure.core/fn (a bug from 2020), ClojureScript frames render their ns/fn properly, and the macroexpansion tooling - a debugging tool in its own right - got a full makeover that deserves (and will get) its own article.

None of these tools is new. That’s rather the point: the 2.0 debugging story is mostly the existing tools becoming trustworthy. A debugger you don’t trust is worse than no debugger at all.

The debugging docs cover everything in detail. Keep hacking!

Articles in the Series

Permalink

Clojure 1.13.0-alpha6

Clojure 1.13.0-alpha6 is now available! Find download and usage information on the Downloads page.

Destructuring additions and changes

  • To get a map of all keys in the input, augmented by the defaults, traversing deeply through nested maps, use the :all directive.

  • Report as errors any use of unbound/unreferenced keys in :or when using any of the new constructs (:defaults, :select et al)

  • CLJ-2972 Destructuring test clean up for :or entries

Try it out

Update your deps.edn :deps with:

org.clojure/clojure {:mvn/version "1.13.0-alpha6"}

Start a REPL with the Clojure CLI (any version) with:

clj -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.13.0-alpha6"}}}

Permalink

Babashka tasks with automatic help and completions

Babashka tasks is a project task manager that is part of babashka since May 2021. It&aposs a practical way to manage a Clojure or other software project. Just invoking bb tasks gives you an overview of everything that is important to developers of this project. E.g. launching a REPL, bumping and releasing new versions, etc.

Example bb.edn:

{:tasks
 {dev   {:doc "Start the dev system"
         :task (clojure "-M:dev")}

  test  {:doc "Run the tests"
         :task (clojure "-M:test")}

  build {:doc "Build an uberjar"
         :depends [test]
         :task (clojure "-T:build uber")}}}
$ bb tasks
The following tasks are available:

dev    Start the dev system
test   Run the tests
build  Build an uberjar

The bb build task runs test first because it :depends on it.

Before: exec and -x

Babashka CLI is a command line parsing library that comes bundled with babashka. Babashka tasks already had some integration with it before, through the exec function. The exec function auto-resolve a var symbol and that var can carry a :org.babashka/cli specification.

bb.edn:

{:paths ["bb"]
 :tasks {dev {:doc "Start the dev system"
              :task (exec &apostasks/dev)}}}

bb/tasks.clj:

(ns tasks
  (:require [babashka.tasks :refer [clojure]]))

(defn dev
  {:org.babashka/cli {:spec {:port {:coerce :int
                                    :default 8080}}}}
  [opts]
  (clojure "-X:dev" opts))
$ bb dev
dev system on port 8080

$ bb dev --port 3000
dev system on port 3000

Babashka&aposs -x flag already invoked that function by its qualified name:

$ bb -x tasks/dev
dev system on port 8080

$ bb -x tasks/dev --port 3000
dev system on port 3000

Since a couple of months, Babashka CLI provides automatic help and completions. Babashka tasks now uses those when configured with either :exec-fn or :cmd.

Opting in with :exec-fn

To opt-in to that new behavior, the dev task becomes:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}}}

bb/tasks.clj:

(ns tasks
  (:require [babashka.tasks :refer [clojure]]))

(defn dev
  "Start the dev system"
  {:org.babashka/cli {:spec {:port {:coerce :int
                                    :default 8080
                                    :desc "HTTP port"}}}}
  [opts]
  (clojure "-X:dev" opts))
$ bb dev --help
Usage: bb dev [options]

Start the dev system

Options:
      --port  HTTP port (default: 8080)
  -h, --help  Show this help

The function&aposs docstring also becomes the task description in bb tasks:

$ bb tasks
The following tasks are available:

dev  Start the dev system

Task explosion

One thing I noticed in some projects is that during their lifecycle, tasks tended to get copied when they need another option.

$ bb tasks
The following tasks are available:

dev                              Start the dev system
dev:with-transactor              Start the dev system with a transactor
dev:with-transactor:with-nrepl   Start the dev system with a transactor and nREPL

I now think that this is a "task smell" and this should be only one task with command line options:

bb/tasks.clj:

(defn dev
  "Start the dev system"
  {:org.babashka/cli {:spec {:port       {:coerce :int
                                          :default 8080
                                          :desc "HTTP port"}
                             :transactor {:coerce :boolean
                                          :desc "Start a transactor"}
                             :nrepl      {:coerce :boolean
                                          :desc "Start an nREPL server"}}}}
  [opts]
  (clojure "-X:dev" opts))
$ bb dev --help
Usage: bb dev [options]

Start the dev system

Options:
      --port        HTTP port (default: 8080)
      --transactor  Start a transactor
      --nrepl       Start an nREPL server
  -h, --help        Show this help

$ bb dev --transactor
dev system on port 8080 transactor=true nrepl=false

Shell completions

To enable completions, you can use bb org.babashka.cli/completions snippet --shell <shell> for your specific shell.

E.g. for zsh, we do this by adding this to ~/.zshrc after compinit:

source <(bb org.babashka.cli/completions snippet --shell zsh)

For Bash, fish, PowerShell, and Nushell, see Completions in the Babashka CLI README.

After doing that, task name completion includes descriptions on auto-complete:

$ bb <TAB>
dev  -- Start the dev system

Task specific option completions includes descriptions:

$ bb dev <TAB>
--help  -h  -- Show this help
--port      -- HTTP port

You can call the completions command directly to inspect its output without a shell, e.g. for debugging:

$ bb org.babashka.cli/completions complete --shell zsh -- dev &apos&apos
--port	HTTP port
--help	Show this help
-h	Show this help

Restricting values with :enum

Use :enum to restrict an option to a fixed set of values:

(defn dev
  "Start the dev system"
  {:org.babashka/cli {:spec {:port {:coerce :int
                                    :default 8080
                                    :desc "HTTP port"}
                             :env  {:desc "Environment"
                                    :enum ["dev" "staging" "prod"]
                                    :default "dev"}}}}
  [opts]
  (clojure "-X:dev" opts))

The help output lists the allowed values:

$ bb dev --help
Usage: bb dev [options]

Start the dev system

Options:
      --port  HTTP port (default: 8080)
      --env   Environment (one of: dev, staging, prod) (default: dev)
  -h, --help  Show this help

The value is also validated:

$ bb dev --env qa
Error: Invalid value for option --env: qa. Expected one of: dev, staging, prod

Usage: bb dev [options]

Run "bb dev --help" for more information.

Shell completion also uses the :enum values:

$ bb dev --env <TAB>
dev  prod  staging

Commands

Add commands to a task with :cmd:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}

         db  {:doc "Manage the database"
              :cmd {"migrate" {:exec-fn tasks/db-migrate}
                    "seed"    {:exec-fn tasks/db-seed}}}}}

Each leaf uses :exec-fn to point to a function:

bb/tasks.clj:

(defn db-migrate
  "Run pending migrations"
  {:org.babashka/cli {:spec {:env {:desc "Environment"
                                   :enum envs
                                   :default "dev"}}}}
  [{:keys [env]}]
  (println "migrating" env))

(defn db-seed
  "Seed the database with fixtures"
  {:org.babashka/cli {:spec {:env {:desc "Environment"
                                   :enum envs
                                   :default "dev"}}}}
  [{:keys [env]}]
  (println "seeding" env))

A top-level :exec-fn can handle bb db. When no command is specified:

$ bb db
No command given.

Automatic help lists the commands and provides separate help for each:

$ bb db --help
Usage: bb db [options] <command>

Manage the database

Commands:
  migrate  Run pending migrations
  seed     Seed the database with fixtures

Options:
  -h, --help  Show this help

Run "bb db <command> --help" for more information on a command.

Shell completion includes commands and option values:

$ bb db <TAB>
migrate  -- Run pending migrations
seed     -- Seed the database with fixtures

$ bb db migrate --env <TAB>
dev  prod  staging

CLI settings with :cli

Use :cli for Babashka CLI settings, such as a help epilog:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}

         db  {:doc "Manage the database"
              :cli {:epilog "Migration code is in resources/migrations."}
              :cmd {"migrate" {:exec-fn tasks/db-migrate}
                    "seed"    {:exec-fn tasks/db-seed}}}}}
$ bb db --help
Usage: bb db [options] <command>

Manage the database

Commands:
  migrate  Run pending migrations
  seed     Seed the database with fixtures

Options:
  -h, --help  Show this help

Run "bb db <command> --help" for more information on a command.

Migration code is in resources/migrations.

For tasks that require code outside of bb.edn, :cli may contain a fully qualified var symbol:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}

         db  {:doc "Manage the database"
              :cli tasks/db-cli
              :cmd {"migrate" {:exec-fn tasks/db-migrate}
                    "seed"    {:exec-fn tasks/db-seed}}}}}

bb/tasks.clj:

(defn- report-error
  [{:keys [msg]}]
  (binding [*out* *err*]
    (println "db:" msg))
  (System/exit 1))

(def db-cli
  {:epilog "Migration code is in resources/migrations."
   :error-fn report-error})
$ bb db migrate --env qa
db: Invalid value for option --env: qa. Expected one of: dev, staging, prod

Top-level :cli options in :tasks apply to every CLI task:

{:paths ["bb"]
 :tasks {:cli tasks/db-cli
         ...}}

Multi-line docs

A :doc value may be a vector of lines:

bb.edn:

db {:doc ["Manage the database"
          "Migrations are applied in order and are idempotent."]
    :cmd {"migrate" {:exec-fn tasks/db-migrate}
          "seed"    {:exec-fn tasks/db-seed}}}
$ bb db --help
Usage: bb db [options] <command>

Manage the database
Migrations are applied in order and are idempotent.

Commands:
  migrate  Run pending migrations
  seed     Seed the database with fixtures

Options:
  -h, --help  Show this help

Run "bb db <command> --help" for more information on a command.

The bb tasks overview prints only the first line:

$ bb tasks
The following tasks are available:

dev  Start the dev system
db   Manage the database

Availability

The task integration is available in babashka 1.13.219. The Babashka CLI features it builds on are in 0.12.85:

org.babashka/cli {:mvn/version "0.12.85"}

Closing remarks

I hope you&aposll enjoy these new additions to bb tasks! The new task keys should be considered experimental and may change in a future version of babashka, depending on feedback from the community.

Permalink

Learning new things is hard

I&aposve been hearing more and more things about jujutsu and want to try it out. It is so hard to learn new things that replace something I&aposve been using almost every day for over 10 years though. The muscle memory is so ingrained and my mental model of what version control even is is so rigid, it&aposs hard to wrap my head around the idea that it could be anything other than git&aposs. My main motivation is that I&aposve "heard" it&aposs better for a) working with stacked branches and b) working on multiple worktrees (called workspaces) at once. These are both things I do way more now because of all the agent-driven-development stuff I&aposm learning than I ever did before and I feel like I am spending a ridiculous amount of time fighting git. But learning new things is so hard. Wish me luck.

Permalink

Clojure vars - notes

Intro

  • named references (symbol) to values of fn
  • they live in a namespace
  • mutable reference to an immutable value

Var is separate from the value so you can redefine it

Note: symbol is just name

Dig Deeper

Functions are actually stored in vars

(defn greet [name] (str "hello" name))
; is equal to 
(def greet (fn [name] (str "hello" name)))
; because defn is macro

Dynamic vars

Allows temporary thread-local bindings

(def ^:dynamic *debug* false)

; temporarily change it
(binding [*debug* true]
 (prinlnt *debug*))

Metadata

(def ^{:doc "A counter"} counter 0)

Symbol vs Var

Symbol

Pice of code that names something

(type 'x)
;; => clojure.lang.Symbol

Writen as 'x
Exists in code and data

Var

Runtime object that point to immutable value
Written as #'x or (var x)
Exists at runtime

Symbol ──▶ Var ──▶ Value
x ──▶ #'user/x ──▶ 42

Example explained

(def x 43)
x
; 43

In the code

  1. find the symbol
  2. resolve it to var #'user/x
  3. dereference var
  4. produce 42

Dig Deeper

Note symbol can refere to local binding (not a Var) or local variable

; local binding
(let [x 10]
  x)

; local variable
(fn [x] x) 

Immutability

Vars are mutable
The values they point are (usually immutable)

The var can change what it points to the data/vale cannot change

Imagine a program without vars
we have immutable values but no way to give them names

Vars and namespaces

Namespace is mapping from symbols to vars
Namespace is object that contains vars

(ns math)
; internaly
; pi --> var
; inc --> var

Referring to another namespace

(ns animals)
(def dog "Labrador")

(ns zoo
  (:require [animals]))

animals/dog ;; 


(ns zoo-2
  (:require [animals :as a])) ; aliases

a/dog ; shorthand for animals/dog


(ns zoo-2
  (:require [animals :refer [dog]])) ; refering vars

dog ; shorthland for a/dog

Dereferencing

Var fully qualified name includes namespace: math/pi or user/x

Example

(def x 42)

When evaluating steps

  1. find var
  2. deref var
  3. 42

you can also do dereferencing by:

@#'x 
; or
(deref #'x)

Evaluation pipeline simplify

Examples

(double-it x)
  1. Soruce code contains symbols (double-it x)
    Symbols is name ('x, it has type clojure.lang.Symbol)

  2. Compiler resolve namespace
    What does the symbol x refer to in the current namespace
    So we get namespace-name/x var
    Note: namespace dosen't store values it stores vars

  3. Var is runtime object so it dereferences it to get values

  4. Invocation (of the function or the value)

Why we have vars, what not directly have values, because with vars we have

  • redefintion (REPL)
  • metadata
  • dynaming binding

Dig Deeper

Special Symbols

' — Quote

Dont evaluate this treat it as data

Example

(def x 42)

This means

  1. Resolve symbol
  2. Find the var
  3. Return it value
'x
; => x

This means

  1. Return symbol

#' — Var Quote

Resolve this symbol to its Var

Example

(def x 42)
#'x
;; => #'user/x

The result is not 42
the result is the Var object
To get a value you can dereference it using @

@#'x
;; => 42
; or

(deref #'x)

When to use it

  • Metadata is attached to the Var
  • Testing and mocking
  • Implementing macros
  • Dynamic Vars and advanced runtime features

Permalink

AI is ruining self-learning

The thing I hate most about AI is that it has made it very difficult to actually learn something new anymore. I have become convinced that if you already know what you are doing, AI can be quite cool. It definitely allows me to develop more and better software than ever before (which I&aposm not convinced yet is necessarily a good thing, but it is cool). The problem is, though, if you don&apost know what you&aposre doing, AI has made it more or less impossible to tell what is true or not anymore. This is actually a huge problem for learning. If you don&apost know what you don&apost know, how can you possibly sift through the sheer volume of slop that has flooded the internet on whatever topic you&aposre trying to learn about? This is actually a frustrating problem.

Permalink

AI agents, Haskell, and the stubbornness of developers

Recently, I found an article about a member of the Haskell Foundation – who was moving his company away from Haskell. The company built a hardware product with Haskell, and they were moving away reluctantly.

I’m not going to repeat the whole thing here (you can read it yourself, but the TL;DR; is: the Haskell compiler and tooling ecosystem was too slow, and that became a bottleneck now that the LLM agent writing the code is so fast.

Now, let’s be clear: I don’t want to start a holy war about “static versus dynamic typing”, so I’m not going to do that. I’m not going to discuss if the merits of static typing are overrated or not, and I’m not going to discuss if dynamic languages are more or less flexible. This was discussed over and over again – and nobody knows the result. And for me, that in-determination is also a result.

What I am going to talk about is the stubbornness of software developers. It’s something that’s been bothering me for a couple of years now, and it still bothers me today. Looking at this article was no different.

The bottleneck is the compiler

So, again, the big TL;DR; of the article: the Haskell tooling is too slow, and the agents are really fast to write code.

If an agent produces working code in, say, 20 seconds, and the Haskell compiler takes 5, 10, 15 minutes to compile… then the compiler becomes a huge bottleneck in the loop where the agent writes code, tests it, sees if it works, and rewrites it to iterate. The agent is done, and the Haskell compiler still didn’t finish.

And here’s the part that got my attention: the author moved from Haskell to Python.

This seems like a weird move to me, because they’re moving from a static language to a dynamic one. But the thing is – it also matches my own experience (I also started with some static typed language, and moved to Ruby/Clojure). And here’s the stubbornness part: this is something that people who like dynamic languages (like me) have been saying for literal years – He experienced something that we iterate, and explain, and prove, again and again and people are still fighting to believe us.

It’s not the LLM

So what’s different right now? It’s not the LLM.

Even supposing the LLM could write excellent code – or even good code (which, to be honest, has not been my experience – It’s mostly code that works, but that I would not write: full of defensive coding, and sometimes even duplications), the real difference between an LLM and a human is: a human programmer is usually proficient in 3, 4, 5 languages, and plenty of programmers are proficient in one or two at most.

The agent, on the other hand, is proficient in multiple languages, and it writes with the same code quality in all of them. Be it good or bad, it’s the same. And that is the interesting part – you now have a mythical “person” (a mythical robot, maybe) – that can produce, with the same ease and the same quality, code in both Haskell and Python: which means you can finally compare the two while holding the developer constant.

And what they found is that the number of defects in the Python code, over a given timeline, was the same as the number of defects in the Haskell code over that same timeline. (Probably fewer in Python, actually – otherwise why would they be moving away from Haskell? But I’m not going to guess). Uhnnn… who could have predicted, except the academic study that studied that in 2016, and that ANOTHER study in 2019 found the same conclusion?

What about quality?

So here’s what makes a good quality software: it’s the developer. And in this case, the machine is the same developer producing the same quality code in two languages. Which means they can measure how much the compiler was actually influencing the time it takes to write something – and what they found is that the compiler wasn’t helping them as much as you’d expect.

I am not going to be that guy and say that the compiler wasn’t helping at all – maybe it was – but the actual time to produce code of quality X was the same in both a compiled, static language like Haskell and a dynamic, interpreted one like Python.

And this, again, matches my experience.

The number of hours that I saw people spend producing correct, and quality code in C# and Java was the same – if not higher – than the time I saw people doing quality code in Ruby. Yes, sure: if you did a very bad Ruby implementation, it was very, very hard to read. But I also saw very, very hard to read C# and Java code. The type system helped me understand what the code was doing – but it did not help me write the fixed version because, for that, I usually had to change the type of some function… and changing that type would cascaded compilation errors over and over the codebase, forcing me either to write “adapter”, or “facades” or other techniques to fix the issue (again, structural typing might help but not by much – you can have the proliferation of very small, specific types that you need to merge and split, essentially making the whole thing a duck-typed code anyway)

Also, if I am writing new types, cascading it over the code, can I actually guarantee the same code quality? After all, am I not changing the shape of the data I’m sending, essentially invalidating the whole “this data have this structure and we’re sure of it”?

Dehumanization, again

You know what bothers me? I met people that worked in Haskell. Most of them would not accept, at all, any argument for a language with “weaker typing” (I wrote how that term is meaningless in the past, so I’m also using it in a meaningless way, just to be 100% sure) than Haskell – some would argue that we need more types and that it’s impossible to guarantee any quality in languages Clojure (because it’s dynamic) and even worse in Ruby or Python (because they are imperative). Few people actually wanted to listen to a different approach, to listen to the “Clojure guy” (usually that was me, before I quit most of the groups because they ended up discussing into theorems, category algebra, etc and less about software) about some different approach to solve a problem.

But… apparently, a machine is ok.

And the worst part? It’s the honeymoon phase. They are doing what LLMs are very good at – capturing a code that already exists, that have very well-define semantics, and porting that to another language. The LLM will probably be able to solve some issues on that migration, and probably will offer a good path for the future.

And that will be attributed to the machine – not the humans that wrote the first Haskell code, well-written, in the first place.

Exactly like they listened to the machine saying that “A dynamic language can produce code faster, and we can iterate sooner on that result” than us, developers of said language saying the exact same thing over the years.

Permalink

Things I wish Datomic had: Map values

Whence this post

JEP 401 – a proposal to add value classes to Java – has been crystallizing for almost six years now, but I only learned about it this morning. I skimmed it, nodding along to myself as I thought “gee, I can’t imagine going back to Java” and “wonder how this might make Clojure more performant?”. But then I thought about some Datomic (actually, Datahike) data remodelling that I’d been working on recently, and I realized that the two areas are connected.

So here’s a braindump, in an effort to clear up my mental image of all this.

A simple example

Consider this Clojure value:

(def prog
  {:program/name    "Apache Maven"
   :program/url     "https://maven.apache.org"
   :program/version {:version/major 3
                     :version/minor 9
                     :version/patch 16}})

If you’re like me, you’ll have a warm, comfortable feeling in your heart looking at this. Plain data at rest. Accessible, transformable. What’s not to love?

Well, now try storing it in Datomic. Easy! Let’s first define a schema:

(def schema
  [{:db/ident :program/name
    :db/valueType :db.type/string
    :db/unique :db.unique/identity
    :db/cardinality :db.cardinality/one}
   {:db/ident :program/url
    :db/valueType :db.type/uri
    :db/cardinality :db.cardinality/one}
   {:db/ident :version/major
    :db/valueType :db.type/long
    :db/cardinality :db.cardinality/one}
   {:db/ident :version/minor
    :db/valueType :db.type/long
    :db/cardinality :db.cardinality/one}
   {:db/ident :version/patch
    :db/valueType :db.type/long
    :db/cardinality :db.cardinality/one}
   {:db/ident :program/version
    :db/valueType :db.type/ref
    :db/isComponent true
    :db/cardinality :db.cardinality/one}])

And now we can transact it (I’ll assume we have a DB connection, conn):

@(d/transact conn schema)
@(d/transact conn [prog])

Done. Now we can check what Maven’s version is:

(let [db (d/db conn)
      mvn (d/entity db [:program/name "Apache Maven"])]
  (:program/version (d/touch mvn)))
;=> {:db/id 17592186045419, :version/major 3, :version/minor 9, :version/patch 16}

It works!

Meh

But I’m not exactly happy about this.

What I’d really like to get is #:version{:major 3, :minor 9, :patch 16}. But Datomic models maps as entities – focal points that bind attributes with values. That’s fine for the top-level program map, but version is not an entity! It’s just a value, like a number or a string. It has internal structure, but, conceptually, it’s just an atomic value, an element of the set of all possible major.minor.patch version numbers.

Yet Datomic forces us to “reify” the version map as an entity. That means that it automatically gets a :db/id. If a new version of Maven gets released, and we transact that fact:

@(d/transact conn
             [[[:program/name "Apache Maven"]
               :program/version
               #:version{:major 3, :minor 9, :patch 17}]])

then Datomic will create a fresh artificial entity, give it the three version attributes, and associate it with the Maven entity. But the old one still exists in the DB! It’s “semi-orphaned” (detached from the rest of the object graph in the current state, but still reachable via history). If Maven for some reason goes back to 3.9.16 via a similar transaction, then we’ll get a third entity that is a duplicate of the first one.

Even worse, there’s nothing stopping us from changing attributes of that entity:

(let [db (d/db conn)
      mvn (d/entity db [:program/name "Apache Maven"])]
  @(d/transact conn [[(-> mvn :program/version :db/id) :version/major 4]]))

Now the identity of version hasn’t changed, but Maven is at 4.9.17, and every other entity that happened to be referencing the artificial version one is at 4.9.17 too! Clearly, this kind of thing should be disallowed.

Also note that I had to say :db/isComponent true in the schema for the transaction to have succeeded at all. isComponent means that the child entity only makes sense in the context of parent; otherwise, I’d have to lift the version map to the top-level of the transaction, give it a temporary id, and use that id to refer to it in the program map.

Two kinds of maps

At this point, I realize there are two kinds of maps: those that describe snapshots of state of some stateful entity at some point in time (like program), and those that are merely juxtapositions of named values (like version). For want of better names, for now on I’ll call these “snapshot maps” and “plain maps”, respectively.

Quoting from Clojure’s Approach to Identity and State:

We need to move away from a notion of state as "the content of this memory block" to one of "the value currently associated with this identity". Thus an identity can be in different states at different times, but the state itself doesn’t change. That is, an identity is not a state, an identity has a state. Exactly one state at any point in time. And that state is a true value, i.e. it never changes.

With this in mind, we could reformulate the distinction as follows: “snapshot maps” are the state of some identity, while “plain maps” are not.

In Clojure, the two kinds of maps look and work exactly the same, but as we saw, they are conceptually very different. It is typically obvious which kind a given map belongs to; e.g. snapshot maps typically have a :id field that holds an integer or a UUID. But sometimes the exact same map can be viewed as either (is {:x 1 :y 2} just a 2D point – a plain map – or a snapshot of a point moving around – a snapshot map?) It’s not just maps either; we could make the same distinction for vectors, or indeed scalar values, but maps are where it’s most commonly encountered.

I find the distinction philosophically interesting. For example, could we envision a variant of Clojure that makes it explicit? Or discriminate between them based on the value’s metadata? Should deref automatically set that metadata? Should operations on the map preserve that metadata?

Besides semantic version numbers, here are some more examples of plain maps that appear frequently:

  • Temporal values. These are typically disguised as instances of java.util.Date or java.time.* classes, but are conceptually immutable collections of fields. For example, java.time.Instant values look like {:epochSecond long, :nano int}.
  • Prices, consisting of an amount and a currency. Back when Fy! used Datomic, we modelled prices as “artificial entities” as described in this post, leading to a massive proliferation of duplicate/orphaned price entities accruing in everyday operation.

What else could we do?

Going back to Datomic: if we want to avoid artificial entities, what alternatives do we have?

  • Store them as strings instead, like "3.9.16". Strings are plain values and they map cleanly onto how we typically denote versions. This may be the right solution if our app also presents versions this way and doesn’t do any fancy processing of it. A potential problem is that strings don’t sort correctly as versions: "11.0.2" is lexicographically smaller than "2.2.1", so the values will appear in an incoherent order in the AVET index. If we want to extract programs with versions in a certain range, the index can’t help us – we’ll need to do a full scan of the DB, then parse and sort manually.

  • Store them as tuples instead, like [3 9 16]. Datomic supports short vectors as values that don’t have an identity. In our case, we can represent versions numbers as 3-tuples of longs (either homo- or heterogeneous ones will do), [major minor patch]. This gives us the correct sorting. The downside is that the rest of our app probably doesn’t think about versions this way, so we need to translate the “domain” values that we use elsewhere to tuples before transacting, and translate it back in the data access layer.

    This leads to increased verbosity (one of the nice things about Datomic is that, most of the time, it lets you get away without having a data access layer at all, as you can use the entities returned by d/entity directly in your domain logic code). Still, I think it’s the right thing to do.

  • Flatten the data: don’t have the :program/version attribute at all, and instead associate the :version/* attributes directly with the program entity. This may make sense in some cases, but loses grouping, which can make it harder to programmatically process such data. Also, what if the program could have many versions?

  • Use Datahike instead of Datomic, and use its unstructured input feature in the “content identity” mode. This still creates artificial entities, but avoids duplication because it automatically infers ids from the map content, so two maps with the same content will refer to the same entity. However, it still has the “accidental mutability” problem with the artificial entity. You have to either opt in to content identity for all sub-maps of the map being transacted, or opt out of it en masse. Plus, it just feels hacky.

In an ideal world…

…I’d like to be able to just define new types in Datomic. Just like there’s built-in supports for java.util.Dates (as :db.type/inst) or java.net.URIs (as :db.type/uri), I’d like to somehow tell Datomic to “support instances of java.time.LocalDate as :db.type/date”. Or, “let :db.type/semver be a map mapping :version/major, :version/minor, and :version/patch to longs”.

How such an API could look like is open to discussion: I don’t have concrete ideas here. Whatever the design, though, I think it’d be a win.

Permalink

Database adapters in Biff 2

I've released two new Biff libraries, both database adapters: biff.sqlite and biff.xtdb. Both of them implement some interfaces used by various other Biff libraries, and they also both implement additional functionality that can be useful to Clojure apps even if they aren't using Biff.

The interfaces

So far, modifying a Biff app to use a different database than the default has been kind of inconvenient, as a result of philosophy about modularity for Biff 1. The ability to swap out defaults was more of an escape hatch so that starting out with Biff doesn't mean your locked into all its choices forever. So that meant if you wanted to swap out the database, you had to, for example, copy and paste all of Biff's sign-in-via-email code and rewrite the queries.

With Biff 2, modularity is becoming more of a first-class citizen. Hence these new "interfaces." And the main interface here addresses the question of, you know, how do you put things in a database. And get them back out. For Biff 2, I wanted be able to package up shared application functionality that needs persistence (such as the authentication module) in a database-agnostic way.

So Biff 2 now defines a key-value store interface which can be implemented by database adapter libraries like the two I've just released:

And my two implementations: sqlite and xtdb. These KV-store functions are exposed via a biff.core module, for example.

In general I try to avoid adding layers to things unnecessarily, so it took a little thinking to arrive at this solution. My main thought was that I'm primarily interested in database-agnosticism for libraries, not applications. Migrating a single application's database is a different use case than wanting to provide functionality for multiple applications using different databases, and it's the latter use case that I'm trying to address.

So I didn't want to introduce some sort of "Biff query/transaction language" that you'd use whenever writing a Biff app; that would be overkill. The database-agnostic libraries I wanted to write have only basic persistence needs, so a key-value interface is sufficient. And that's an easy interface for database adapters to implement.

The second biggest interface-type-thing is that both adapters come with make-resolvers functions (sqlite / xtdb) which generate a set of biff.graph resolvers for the tables in your application schema.

The features

Besides the key-value functions and a couple other doodads, the remaining functionality in these database libraries is just whatever stuff I thought would be nice to have when writing a Clojure app using the respective databases. From biff.sqlite's README:

  • Sane defaults like WAL mode, STRICT tables, etc.
  • Backup/restore via Litestream.
  • Migrations via sqldef.
  • Rich schema types: define columns as e.g. booleans, instants, nested maps, etc; and biff.sqlite converts them to/from SQLite's supported types (ints, blobs, etc).
  • Validate transactions based on centralized authorization rules you define (helps to keep LLM code secure).

And for biff.xtdb:

  • Start up an in-process node with high-level config defaults.
  • Custom :biff/upsert and :biff/assert-unique transaction operations.
  • Optionally enforce Malli schemas on write.
  • Define centralized authorization rules for validating transactions.

biff.sqlite is a chonkier library due to all the logic needed for supporting rich schema types.

Write your own database adapter

Finally, I have written a database adapter guide which lists all the interfaces that adapters should implement and suggests additional features that may or may not be relevant for any given database. Between that guide and the two sqlite/xtdb reference implementations, I'm hoping it should be straightforward to implement an adapter for whatever database you want to use. I will probably only maintain the SQLite and XTDB adapters, but I might publish some as-is code for other databases which could be picked up and maintained anyone who so chooses.


Plug: my team is hiring for a senior software engineer, writing ClojureScript and Python. We make optimization software for clean energy projects.

Permalink

Python Fundamentals for a JavaScript Developer

I'll guide you through Python fundamentals by comparing concepts with JavaScript. Let's start!

1. Hello World & Basic Syntax

JavaScript

console.log("Hello World");
let x = 5;

Python

print("Hello World")
x = 5  # No semicolon, no let/const

Key Differences:

  • No semicolons in Python
  • Indentation matters (replaces curly braces)
  • Comments use # instead of //

2. Variables & Data Types

JavaScript

let name = "Alice";  // string
let age = 30;        // number
let isStudent = true; // boolean
let scores = [95, 87, 91]; // array
let person = {        // object
    name: "Bob",
    age: 25
};
let nothing = null;
let notDefined = undefined;

Python

name = "Alice"        # str
age = 30              # int (or float for decimals)
is_student = True     # bool (capital T/F)
scores = [95, 87, 91] # list (mutable)
person = {            # dict (dictionary)
    "name": "Bob",
    "age": 25
}
nothing = None        # Python's null/undefined

Key Differences:

  • Python uses snake_case (not camelCase)
  • True/False capitalized
  • None instead of null/undefined
  • Lists ≈ Arrays, Dicts ≈ Objects

3. Control Flow

JavaScript

// If-else
if (age >= 18) {
    console.log("Adult");
} else if (age >= 13) {
    console.log("Teen");
} else {
    console.log("Child");
}

// For loop
for (let i = 0; i < 5; i++) {
    console.log(i);
}

// While loop
let count = 0;
while (count < 5) {
    console.log(count);
    count++;
}

Python

# If-else (indentation instead of braces)
if age >= 18:
    print("Adult")
elif age >= 13:  # NOT else if
    print("Teen")
else:
    print("Child")

# For loop (more like for...of in JS)
for i in range(5):  # range(5) = [0, 1, 2, 3, 4]
    print(i)

# Iterate over list (like for...of)
for score in scores:
    print(score)

# While loop
count = 0
while count < 5:
    print(count)
    count += 1  # No ++ operator in Python

4. Functions

JavaScript

// Function declaration
function add(a, b) {
    return a + b;
}

// Arrow function
const multiply = (a, b) => a * b;

// Default parameters
function greet(name = "Guest") {
    return `Hello ${name}`;
}

Python

# Function definition (def instead of function)
def add(a, b):
    return a + b  # Indented body

# Lambda functions ≈ Arrow functions
multiply = lambda a, b: a * b

# Default parameters
def greet(name="Guest"):
    return f"Hello {name}"  # f-strings like template literals

# Multiple return values (tuples)
def get_coordinates():
    return 10, 20  # Returns a tuple (10, 20)

x, y = get_coordinates()  # Destructuring assignment

5. Data Structures Comparison

Arrays/Lists

// JavaScript Arrays
let arr = [1, 2, 3];
arr.push(4);          // [1, 2, 3, 4]
arr.pop();            // [1, 2, 3]
let sliced = arr.slice(0, 2);  // [1, 2]
# Python Lists
arr = [1, 2, 3]
arr.append(4)         # [1, 2, 3, 4]
arr.pop()             # [1, 2, 3] (removes last)
sliced = arr[0:2]     # [1, 2] (slicing syntax)
arr.insert(1, 99)     # [1, 99, 2, 3]

# List comprehension (unique to Python)
squares = [x**2 for x in range(5)]  # [0, 1, 4, 9, 16]

Objects/Dictionaries

// JavaScript Objects
let person = {
    name: "Alice",
    age: 30,
    greet() {
        return `Hello, I'm ${this.name}`;
    }
};
console.log(person.name);
console.log(person["age"]);
# Python Dictionaries
person = {
    "name": "Alice",
    "age": 30,
    "greet": lambda self: f"Hello, I'm {self['name']}"
}
print(person["name"])  # Access with brackets
print(person.get("age"))  # Safer access

# Methods don't naturally have 'this' context
# Usually you'd use classes for that (see below)

6. Classes & OOP

JavaScript (ES6+)

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    greet() {
        return `Hello, I'm ${this.name}`;
    }

    static species = "Human";
}

const alice = new Person("Alice", 30);

Python

class Person:
    species = "Human"  # Class attribute (static)

    def __init__(self, name, age):  # Constructor
        self.name = name  # Instance attribute
        self.age = age

    def greet(self):  # Methods always have self parameter
        return f"Hello, I'm {self.name}"

    @staticmethod
    def static_method():
        return "This is static"

alice = Person("Alice", 30)
print(alice.greet())  # No parentheses needed for self when calling

7. Modules & Imports

JavaScript (ES6 Modules)

// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }

// main.js
import { PI, add } from './math.js';
import * as math from './math.js';

Python

# math.py
PI = 3.14159
def add(a, b):
    return a + b

# main.py
from math import PI, add
import math  # Then use math.PI, math.add
import math as m  # Alias

8. Error Handling

JavaScript

try {
    throw new Error("Something went wrong");
} catch (error) {
    console.error(error.message);
} finally {
    console.log("Cleanup");
}

Python

try:
    raise Exception("Something went wrong")
except Exception as e:  # 'as' instead of variable declaration
    print(f"Error: {e}")
finally:
    print("Cleanup")

9. Async Programming

JavaScript (Promises/Async-Await)

// Promise
fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data));

// Async/await
async function getData() {
    const response = await fetch(url);
    return await response.json();
}

Python (Async/Await)

import asyncio
import aiohttp  # External library for HTTP

async def fetch_data(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.json()

# Run async function
asyncio.run(fetch_data('https://api.example.com/data'))

10. Common Patterns & Tips

1. Type Checking (Python is dynamically typed but has type hints)

def add(a: int, b: int) -> int:  # Type hints (optional)
    return a + b

2. String Formatting (multiple ways)

name = "Alice"
# f-strings (Python 3.6+, like template literals)
print(f"Hello {name}")

# .format() method
print("Hello {}".format(name))

# % formatting (older style)
print("Hello %s" % name)

3. Tuple vs List

# List - mutable
my_list = [1, 2, 3]
my_list[0] = 99  # OK

# Tuple - immutable
my_tuple = (1, 2, 3)
# my_tuple[0] = 99  # ERROR!

4. Sets (unique unordered collection)

my_set = {1, 2, 3, 3, 2}  # {1, 2, 3} (duplicates removed)
another_set = set([1, 2, 3, 4])  # Alternative creation

Quick Reference Table

JavaScript Python Notes
let x = 5; x = 5 No declaration keywords
const arr = [] arr = [] No const, just assign
null / undefined None Single null value
=== strict equality == and is == value, is identity
array.length len(list) Function, not property
array.map() List comprehensions [x*2 for x in arr]
for (let i=0; i<n; i++) for i in range(n) Different pattern
function fn() {} def fn(): def keyword
obj.property dict["key"] or obj.attr Depends on type
class MyClass {} class MyClass: Colon and indentation

Practice Exercise

Convert this JavaScript code to Python:

function filterEvenSquares(numbers) {
    return numbers
        .filter(n => n % 2 === 0)
        .map(n => n ** 2);
}

const result = filterEvenSquares([1, 2, 3, 4, 5]);
console.log(result); // [4, 16]

Python solution:

def filter_even_squares(numbers):
    return [n**2 for n in numbers if n % 2 == 0]

result = filter_even_squares([1, 2, 3, 4, 5])
print(result)  # [4, 16]

Next Steps

  1. Install Python and a good IDE (VS Code with Python extension works well)
  2. Practice by rewriting your JS projects in Python
  3. Explore Python-specific features: decorators, generators, context managers
  4. Learn popular libraries:
    • Web: Flask/Django (Express equivalents)
    • Data: NumPy, Pandas
    • AI/ML: TensorFlow, PyTorch

The main mindset shift: Python emphasizes readability and simplicity over cleverness. You'll write less code to accomplish the same tasks!

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.