m0serve Realtime from a synchronous Python app

Storage and MAX in the m0 wheel — 2026-09-25

The m0 wheel carried five source trees and no way to a database, and no word about MAX. This round adds both, on the two decisions the rounds before it made: m0-sqlite opens its library at run time (D49), so it can ride where m0 build takes no link flag; and an m0 application reaches N cores as threads (D48), so a binary that links MAX's parallel runtime has a served shape. SPEC N39–N41 are the rows; D50 is the decision; m0 is 0.3.0.

hatch_build.py's table gains packages/m0-sqlite/src and packages/m0-postgres/src, and smoke-m0-wheel's second spelling of it gains the same two lines — the guard, as before. Nothing else about the wheel changes: the trees are git ls-files of the two directories, mapped under m0/_mojo/m0_sqlite and m0/_mojo/m0_postgres.

What makes them shippable is what they do not do. Neither names its C library on any link line — libpq never did, libsqlite3 stopped in D49's round — so an application's m0 build needs no flag, and m0 test, which is mojo run and resolves nothing from its own image, can open a database. The wheel smoke writes a test that does (test_store.mojo, in-memory SQLite through the installed tree) into its project and requires it green under m0 test; that is the property the trees ride on, held from outside.

The library still has to be on the machine. macOS has it in the shared cache; Debian's slim image does not, so the scaffold's Dockerfile installs RUNTIME_LIBS in the runtime stage — libsqlite3-0 by default, libpq5 one build argument away — and records what it installed in about.json's libs. The image smoke asks the running container for /usr/lib/*/libsqlite3.so.0, because "the storage package is in the wheel" and "the image can open a database" are two claims.

The live scaffold keeps its kick count

A template with a database in it is what shows an application author the rules. store.mojo is one row in SQLite — counters(name, value) — and the HANDLER owns it: LiveHandler.make opens the connection, and the host runs make once per worker, per loop and per handler-pool thread, always after it forks — a SQLite connection carried across fork() is the one thing SQLite's own documentation says not to do. The kick view counts in the database inside its own request (UPDATE … value = value + 1, so two workers kicking at once both count) before it touches the board's word, and /stats reads the total back from the file; the board's word keeps kicks since start, for the wave alone. The schema is created inside an IMMEDIATE transaction, for the reason datastar_todo found at two workers.

That is the second shape. The first had the PRODUCER own the store — opened at its first step, the board seeded from it, the count written back whenever it moved — and CI's ubuntu leg found it in one run: after smoke.sh's restart, /stats said {"steps":0,"kicks":0}. The producer polls, every 50 ms while nobody watches, and the smoke's kick landed inside one poll and its SIGTERM before the next, so the write never ran; locally the same race had simply not fired. A count that must survive a restart is written in the request that changes it, where the 204 the client sees is a committed row, and a producer that polls is the wrong owner for it.

smoke.sh restarts the server on the same file and polls /stats for the kick; smoke-scaffold runs that script, and sabotage-scaffold holds both halves: the view's write reverted is caught at the wire's first /stats, which reads the database, and the file swapped for an in-memory store — counted, then forgotten — is caught only by the restart.

The move left the board's word with nothing on the wire watching it. sabotage-scaffold's older kick rule drops st.board.add(B_KICKS, 1) and expected the wire's /stats to notice, which it did while /stats read the word; reading the database, it cannot, and the 2026-09-26 pre-release run reported the rule MISSED with the whole scaffold green. The word's one consumer is the wave, so the wire now watches the wave: it keeps its stream open across the kick and requires a later frame whose lowest bar stands above the highest bar the wave drew before it. Unkicked, a bar never passes 20 plus the sine's full swing of 30; the step that applies a kick adds 40 to every bar, so the frame that shows it clears every frame before it outright, and with the word dropped no frame ever does. The template's own test opens the store in memory (open() wants a file it can put in WAL mode) and runs under uv run m0 test with nothing linked, on both platforms, which is the first thing this round wanted.

The views template stays in memory on purpose. Its list is the smallest htmx example there is, and a second database template would be a second copy of the rules above; an application that wants both starts from live's store and views' routes.

MAX is a pinned companion, not a dependency

max-core pins its own mojo-compiler exactly, so a MAX beside the wrong mojo is a second toolchain in the venv. The wheel therefore records the pair it was gated beside: gated_max, read from the root's max dependency group — the one smoke-parallel-runtime syncs — the way gated_mojo is read from the root pin, refusing anything but one exact max-core==X. max-gated joins the ONE list in checks.py, after mojo-gated: absent, it passes saying how to add MAX; present at another version, it refuses with the sentence naming the fix; present at the gated version, it passes naming it. The doctor's JSON carries gated_max and max beside their mojo twins.

The wheel smoke's refusal arm is a stub max-core 9.9.9 beside the REAL mojo, so mojo-gated passes and the sentence is max-gated's alone; a stub, because MAX is a gigabyte of wheels and nothing of it needs to run to read a version from metadata. What does need to run is measured where MAX is already installed: smoke-parallel-runtime gained a phase that runs the release recipe — relocate.py, then bundle_artifact.py, the two scripts the wheel ships unedited — on a copy of the probe and requires libAsyncRTMojoBindings to land beside it, then answers --doctor and serves /par from that directory with the build venv unreachable. The bundler discovers the closure from what the binary names rather than from a list, which is why it needed no change: MAX's runtime sits in modular/lib like the rest.

The scaffold says the rest: pyproject.toml carries the uv add line with the gated version substituted (__M0_MAX_VERSION__, the fourth token, pyproject.toml-only like the other two versions), and AGENTS.md the four rules — the capture list on parallelize's closure, where it belongs (a producer's step, a heavy view rarely busy twice), that a binary linking it is served as threads and refused as forked workers (E32), and that the release build bundles its runtime.

What building it turned up

uvx --from WHEEL runs the m0 it cached, not the wheel it was handed. The first run of sabotage-scaffold's new rule — the __M0_MAX_VERSION__ substitution reverted in new.py, the wheel rebuilt, m0 new run through uvx --offline --from dist/m0/…whl — was MISSED: the written pyproject.toml carried the version, substituted. uv keys a tool environment by the wheel's name and version, and every smoke's wheel is rebuilt under the one version 0.3.0+tree, so on a machine that had run the smoke before, uvx served the m0 it built the first time. Measured with a marker printed from new.py: absent through a plain uvx, present through uvx --refresh-package m0 and through --no-cache. The venv half of the smokes already carried uv sync --refresh-package m0 for the same hazard; the three uvx sites (the scaffold, image and dev smokes) now carry --no-cache, and the rule is caught. --no-cache and not --refresh-package, because CI's uv (0.12.19) refuses the latter beside --offline — the argument '--offline' cannot be used with '--refresh', the first CI run of this branch — while the container's 0.8.17 accepted the pair, and offline is the point of that step. Measured on both: 0.8.17 serves the stale environment through a plain uvx and 0.12.19 does not (it noticed the rebuilt wheel), and --offline --no-cache runs the wheel handed to it on either. CI's fresh runner never saw the stale cache, which is the shape of a gate that passes with the bug live.

What follows

An application outside the tree on live's store, deployed with a volume, is the soak this layer still lacks (poe milestones). Postgres in a scaffold waits for one that needs it: the package rides, the image takes libpq by one argument, and the rules are in AGENTS.md.