The Package System That Was Mostly Already There

A language that compiles itself and runs in a browser still couldn't be shared: there was no way to depend on someone else's code. The fix came from a real consumer, working backwards from its pain — and the surprise was how little of it was new. The resolver already resolved cross-package imports correctly; what was missing wasn't language or semantics but tooling. A manifest, a lockfile, and a way to vendor the runtime host turned a released binary into something that could both build and run an app with no compiler checkout at all.

merepackagestoolingdogfoodlanguage-design

By the end of the eighth part, Mere could do a surprising amount: compile itself, run in a browser, serve HTTP, talk to Postgres, hold four backends in exact agreement. And yet it could not do the most ordinary thing a language is expected to do — let one program depend on another that someone else wrote. There was no way to say “I need this library” and have it appear. That gap doesn’t show up while you are building the compiler, because the compiler is one tree you own entirely. It shows up the moment you try to build a second thing on top of the first.

The shape came from a consumer, not a design

The temptation with a package system is to design it in the abstract — registries, version ranges, resolution algorithms — and arrive at something large before anyone has used it. Mere took the opposite route. A real application, a small collaborative notes server written in Mere, was made to depend on the compiler’s own libraries, and the requirements were read off the pain that produced.

The pain was concrete. There was no install step, so dependencies were copied by hand out of the monorepo. There was no way to pin a version, so what you actually depended on was written down in prose in a VENDOR.md and immediately drifted. And the names collided: the libraries imported each other with relative paths, and the on-disk directory names the vendoring produced didn’t match the names those imports expected, so cross-package imports broke. Three problems, each one a sentence, each one from a program that actually existed. That list was the specification.

The resolver was already enough

The instructive part came next. Having listed the requirements, the natural assumption was that satisfying them meant changing the language — a new import syntax, a resolution pass, compiler support for versions. It didn’t. The resolver already in place resolved everything correctly.

That resolver, from the first version of the package system, walks up from an importing file toward the filesystem root looking for a .mere_modules/ directory, exactly the way Node walks up looking for node_modules. Once it finds one, an import of "pkg/module.mere" reads .mere_modules/pkg/module.mere, and nested imports from inside a vendored module walk up from their location and find the same top-level directory. Cross-package imports already Just Worked, as long as everything lived under one project root. The dogfood had proven this by accident. So the whole of the new work was not semantics but tooling: something that populated .mere_modules/ correctly, and then got out of the way. The compiler doesn’t care how the files got there.

A manifest and a lockfile, and nothing else

What that tooling needed was small. A manifest — mere.toml — declares a package and its dependencies, each dependency a git repository at an exact commit, optionally a subdirectory of that repository so a single monorepo can host many packages. mere install reads the manifest, fetches each dependency at its pinned commit, follows the relative imports between packages to pull in the transitive ones too, and writes them into .mere_modules/. Then it records what it did in a mere.lock — the resolved full commit hash for each package plus a content hash over the whole set — so a second install reproduces the first exactly.

The deliberate omissions are as important as what’s there. A dependency pins an exact commit, not a version range, so there is no resolution to do — no satisfiability search, no diamond to solve. If two packages want different revisions of a shared dependency, the walk-up decides, and the fix is to sort it out at the deployment layer rather than in a solver. There is no central registry. These are not gaps waiting to be filled on a schedule; they are things left unbuilt until a program needs them, which is the same discipline the rest of the language was built on.

The naming knot

One of the three original pains deserves its own note, because it is the kind of detail that quietly decides whether the whole thing works. A registry naturally wants namespaced names — mere-http, mere-db — so that packages from different authors don’t collide. But the libraries themselves import each other by bare relative paths, written when they lived side by side in one tree: ../log/log.mere, not ../mere-log/log.mere. If the installer vendored a package into a directory called mere-log, every relative import that expected log would break.

The resolution was to separate the two naming worlds. The name on disk, and the name in an import, is the bare one — http, db, log — so that .mere_modules/ mirrors the source tree and every relative import keeps resolving. The mere- prefix lives only in the repository coordinate: it is where the package is fetched from, the future standalone repo name or the monorepo subdirectory, and it never appears in a path the compiler sees. The manifest is the one place the two are tied together, which is exactly where a mapping like that belongs.

Shipping the runtime, not just the source

There was a second gap, adjacent to the first, that a package system for source code doesn’t obviously cover. A compiled Mere program is a .wasm file, and a .wasm file does nothing on its own: it imports functions like puts, read_file, http_serve from its host, and those host functions are small pieces of JavaScript that live in the compiler’s own repository. So even with every source dependency vendored, a compiled app still couldn’t run without a checkout of the compiler — which defeats much of the point of compiling it.

The fix extends the manifest with a [host] section that fetches those host pieces too, flattening their internal references so the bundle stands alone in a .mere_host/ directory, and adds a mere serve that runs a compiled server against it. The consequence is the one that matters: a released mere binary plus mere install is now enough to both build an application and run it, with no compiler source tree present at runtime at all. Distribution, it turned out, was never only about source; the runtime host had to travel with it, and once it did, the loop closed.

What the smallness bought

The whole package system is intentionally minor — no registry, no version solver, an install that fetches exact commits and a resolver that was already written. It would be easy to read that as unfinished. It is better read as the same restraint the series has practiced throughout: build the smallest thing that removes a real, present pain, and let the shape of it be dictated by a program that actually needs it rather than by an idea of what a package system is supposed to contain. The consumer wrote the specification. The language, mostly, already satisfied it. What remained was to notice that, and to build only the rest.

← Back to Mere: Building a Language