ESC
Type to search...
S
Soli Docs

Changelog

Notable changes to Soli, newest first. Each release is a section below; the Unreleased block collects what's landed on main since the last tag. For the full commit-level history see CHANGELOG.md in the repository.

Unreleased

Changes landing on main since v2.5.5.

v2.5.5 — 2026-09-25

Testing

  • Stand in for a third-party service with mock_http_route. The test HTTP server used to answer {"ok":true} on every path. mock_http_route(path, status, body) now scripts what a path answers, and mock_http_last_body(path) returns what the app sent there. The server is a real local socket, so your app’s test server reaches it as it would reach the real service — enough to play an OpenID provider end to end and prove the PKCE verifier your app sends matches its challenge. See Mock HTTP Services.
  • Worker rows show where a spec lives. The live grid printed api_debit_spec for controllers/api/api_debit_spec; it now shows the path under the tested directory, and a narrow terminal shortens the folders, not the filename.

Fixes

  • Coverage counts lambda bodies. A lambda’s lines were credited to the file that was running — the spec, or the request’s controller — instead of the file it is written in, so they showed as never executed. A service building its results in lambdas could sit at 72 % with every one of those lines exercised by a passing test. Expect the coverage of services and helpers that use lambdas to go up.
  • No more /file_spec.sl in the run summary. A spec at the top of the tested directory was printed with a stray leading slash.
  • Pages no longer come back in the previous language. The server keeps rendered pages in a cache for each worker, keyed by template and data but not by locale. After a language switch, a page could still be served in the old language, depending on which worker took the request. The locale is now part of the key. Requests that carry a session also skip this cache: a layout that shows the signed-in user renders differently for each person, and the cache could not see that.

v2.5.4 — 2026-09-25

Dev tools

  • Slow queries at /__soli/slow_queries. Every ORM query that takes SOLI_SLOW_QUERY_MS (default 200) or longer — SoliDB over HTTP or the native driver, Postgres, MySQL, SQLite; in requests and in jobs — is grouped by shape (numbers and value lists taken out, and quoted strings in SDBQL) into a _soli_slow_queries table in the app’s own database. Each shape keeps its count, average, slowest and total time, the request or job that ran it last (GET /orders → orders#index), 24 hours of hourly counts, and its five slowest runs with the query as it ran and its bind values — secret-named binds redacted, long ones cut, none at all with SOLI_SLOW_QUERY_BINDS=off. Sort by impact (total time), slowest, frequent or recent. A fast query pays one comparison; slow runs are written off the request path, in one-second batches. Same gate as the errors page (SOLI_SLOW_QUERIES_USER/_PASSWORD/_TOKEN or SOLI_ADMIN_*), at most 1000 shapes per app, off under APP_ENV=test; SOLI_SLOW_QUERIES=off disables it. Linked from the dev bar’s tools panel. See Observability.
  • Notifications for errors and slow queries. Four events — error.new, error.regressed (a resolved error failed again), error.spike (SOLI_NOTIFY_SPIKE, default 50 occurrences in 5 minutes, not for ignored groups) and slow_query.new — go to every destination configured: SOLI_NOTIFY_WEBHOOKS (Slack, Microsoft Teams as an Adaptive Card, Discord and Google Chat recognised by host; any other URL gets the event as JSON, signed with X-Soli-Signature when SOLI_NOTIFY_SECRET is set, all through the Webhook.enqueue SSRF guard), SOLI_NOTIFY_EMAILS through the app’s own mailer (the dev inbox in --dev), and app/jobs/soli_notification_job.sl when the app has one, enqueued with the event hash for PagerDuty, SMS or anything else. At most one message per event and group per SOLI_NOTIFY_THROTTLE (default 15 minutes); SOLI_NOTIFY_EVENTS narrows the set. Sent from a per-app notifier thread, so a slow webhook never delays a request. The errors and slow-queries pages say where notifications go, by product name, never the URL. See Observability.

Web

  • New apps' agent guides know about all of this. The CLAUDE.md written by soli new (refresh an existing app with soli update docs) lists error tracking, slow queries, notifications and instant navigation, and app/views/CLAUDE.md explains how views behave under instant navigation, including the morph layout meta.
  • Instant navigation can morph instead of swap. Put <meta name="soli-nav" content="morph"> in a layout (or set SOLI_NAV=morph for every page) and a click patches the live <body> into the new page instead of replacing it. Nodes that did not change stay the same DOM nodes: a sidebar keeps its scroll position, an open <details> stays open, typed input survives. Elements pair by id, then by tag; unchanged scripts are not re-run; Alpine components are replaced whole; pages with x-teleport fall back to a swap. Instant Navigation

Fixes

  • x = f() rescue nil no longer makes x a Null. The type checker gave a postfix rescue the type of its fallback alone, so the x[0] that follows a nil check was refused as cannot index Null — before the script's first line ran, with none of its output printed. The two sides are now widened together, as a ternary's branches are: 1 rescue 0 stays an Int, anything rescue nil is Any. A bare x = nil now reads like let x = nil, so the next assignment is no longer refused as expected Null.

v2.5.3 — 2026-09-25

Added

  • X509.info(cert) and X509.peer_certificate(host, port?, timeout?). Watch certificate expiry without shelling out to openssl: info reads the validity dates, days_left (negative once expired), subject, issuer and SAN names; peer_certificate does a TLS handshake and returns the same for the certificate a server presents — without validating the chain, so an expired or self-signed certificate is reported instead of refused. It goes through the same SSRF guard as HTTP. See Built-ins.

Auth & security

  • The data passed to render() is redacted in error samples. When a template failed mid-render, its render() hash reached the stored error sample, the /__soli/errors page and the stderr env: line unredacted — a reset_token passed to the view was shown in full. It now gets the same redaction as the handler’s locals.
  • X-Api-Key and api-key are secrets. Secret names only matched with underscores, so a hyphenated API-key header or param was stored in error samples and replayed on the dashboard’s curl line. Names now match whatever the separator, request headers also get the exact credential-header list, and the curl line never carries Authorization, Cookie or API-key headers.

Fixes

  • Error tracking keeps at most 1000 groups per app. Past the limit, new kinds of error are counted together in one overflow group, each sample keeping its own message; stored groups keep counting and deleting groups makes room. A client that chooses an error’s wording can no longer grow the table without bound.
  • An apostrophe no longer breaks grouping. can't find X and can't divide were one group, and a quoted value after User's leaked into the fingerprint. A single quote now quotes only at word boundaries.
  • A crash in the error writer no longer stops tracking silently. A panic in one write is caught and counted; a writer that stopped is restarted on the next error. The page reports dropped, failed and lost occurrences separately, per app.
  • A database hiccup is not a missing error group. A transient SoliDB read error no longer makes the tracker insert a new group over an existing one, and the dashboard answers 500 when it cannot read or update a group.

v2.5.2 — 2026-09-24

v2.5.1 was tagged but never published (its lock file was broken); these changes shipped here.

Dev tools

  • Built-in error tracking at /__soli/errors. Every request that ends in a 500 is grouped by fingerprint and stored in the app’s own database (SoliDB, Postgres, MySQL or SQLite) — a self-hosted stand-in for Sentry with nothing to install. Ids, numbers and quoted values don’t split a group, and neither does editing code above the bug. Each group keeps its count, first and last seen, and its five newest occurrences with stack, request, locals and a curl line to replay it locally, all redacted before storage. Resolve, ignore or delete a group; a resolved one that fails again comes back regressed. Recording happens off the request path, in one-second batches. On by default (SOLI_ERRORS=off to stop); in production the page stays 404 until SOLI_ERRORS_USER/_PASSWORD or SOLI_ERRORS_TOKEN is set. See Observability.
  • The /__soli/* pages share one look. Errors, jobs, the mail inbox and the mailer and component catalogs now have a common top bar to move between them, readable sans-serif text with monospace kept for code and ids, status tags, and proper empty states. The error list shows how long ago each group was seen, a 24-hour trend, the last request that hit it, and a count on each tab.

Auth & security

  • One set of credentials for every operator page. SOLI_ADMIN_USER / SOLI_ADMIN_PASSWORD / SOLI_ADMIN_TOKEN open both /__soli/jobs and /__soli/errors, beside each page’s own variables.
  • The raw request body no longer reaches the error log. The env: line of a production error redacted form and JSON fields by name, but printed req.body — the raw password=… string — in full. It is now [REDACTED], as the request snapshot above it already was.

v2.5.0 — 2026-09-24

Build

  • The native SoliDB driver ships in every build. Release binaries, the Docker image and cargo install now include the MessagePack driver. Until now, SOLI_DB_DRIVER=1 on a published binary silently stayed on HTTP, so the read speed-ups below never reached you. Nothing changes unless you opt in: set SOLI_DB_DRIVER=1 to use it. CI now tests it against a real SoliDB. See Configuration for SOLI_DB_DRIVER and SOLI_DB_DRIVER_QUERY.

Performance

  • SoliDB reads decode their rows once, straight into Soli values. On the native driver (SOLI_DB_DRIVER=1), Model.all, pluck(...).all and other plain query-builder reads now receive Soli values directly from solidb-client, instead of building a JSON tree and converting it. Decoding had been 35–40% of the CPU on a database-backed page; with the client’s own fix in v1.2.0, the framework suite at 200 concurrent connections goes from 59.9k to 79.3k req/s on /db and from 57.7k to 74.9k on /db-template, with CPU per request down by about 30%. Output is byte-identical; mocks, SQL adapters and the HTTP transport are unchanged.

Fixes

  • soli fmt no longer writes a stray ; line that does not parse. A postfix guard whose value is long enough to wrap — return [true, false, false, false] if which == "t" — is printed as an if … end block, and when the next line started with [ the formatter still added a ;, alone on its own line, which broke the file. Blocks end with end and now get no ;.

EUI

  • Styles in Tailwind’s classes. The scaffolded catalogue gains a sixth file, eui_builders_tw.sl, and tw("flex items-center gap-3 rounded-lg bg-white px-4 py-2 shadow-sm hover:bg-gray-50"): the resting style and its hover:, active:, focus: and disabled: deltas. A "tw" key in the style given to node, column, row or stack is read the same way, with the states wired as local handlers; control and stateful take classes too. Spacing is the space scale’s indices and a colour is a role — a gray is a surface, a border or an ink depending on what it paints — so nothing new goes on the wire. A class with no equivalent (tracking-*, gradients, per-corner radius, transforms, dark:…) raises with its name and the reason instead of being dropped. See Styling.
  • tw() takes breakpoints, spacing between children, dividers and text transforms. sm: … 2xl: resolve on the server, mobile first, against the viewport width you pass — tw(classes, width), tw_style(classes, false, width), or "vw": width beside "tw" on a node — and a breakpoint class with no width raises rather than guessing. space-x-4 on a row, space-y-2 on a column and gap-x/gap-y along the line become the gap; divide-y divide-gray-200 is laid onto every child but the first by node(); uppercase, lowercase and capitalize are applied to the string by text(). mx-auto, block, relative, isolate, select-none and focus-visible: are taken where EUI already behaves that way; a half step such as py-1.5 is still refused, and names the two nearest steps. See Styling.
  • The half spacing steps: py-1.5, px-2.5, gap-3.5, and 20 and 32. They were the classes tw() refused most often in first-draft screens — 1.5 alone 34 times in ten. EUI protocol version 6 appends them to the space scale as indices 13–17 (6, 10, 14, 80 and 128 px), and tw() maps them there. A session whose client is older than 6 is sent the nearest step it has, rounding down on a tie, so the view is written once and no client is refused. See Styling.
  • Gradients, and pulse and bounce. A style’s bg may name a linear gradient — {"gradient": {"to": "right", "stops": ["accent.base", ["#ff80b5", 255]]}}, two or three stops, a side, a corner or an angle — drawn as CSS draws one and, when its stops are roles, right in dark mode too. animation takes "pulse" and "bounce", which play on the client’s clock with nothing on the wire. tw() takes bg-gradient-to-r from-indigo-600 via-info to-[#ff80b5] and animate-pulse/animate-bounce. All three are EUI protocol 6; an older client is sent the first stop as a solid background and no animation. See Styling.
  • The scaffolded catalogue looks like Tailwind UI. 14 px labels and body text; white fields and secondary buttons inside a hairline, the primary a filled accent with a small shadow, every button one control (36 px) tall; field labels in the default ink rather than muted; cards at radius 2 with a faint ring, dialogs, menus and toasts a step up; tables with a semibold header, hairline rows and a local hover; rounded-md badges and chips; underlined tabs and a sidebar whose current row is a grey wash. table_row takes {"dense": true} for a virtualised list, and accordion takes children as well as body.
  • A field says what goes in it while it is empty. input, textarea, field and every *_field builder take {"placeholder": "you@example.com"}. The EUI client draws it in the muted ink while the field is empty, drops it at the first character, never sends it as the value, and reads it to a screen reader as the field’s placeholder — so the grey line an application used to fake with a stacked text node is one key. See Input widgets.

v2.4.2 — 2026-09-24

Fixes

  • A zero-argument action runs on the tree-walker again. 2.4.0 sent the scaffold’s def index to the bytecode VM, and actions that had always worked failed there in production — while the same code ran fine as a script and on 2.3.6. They are back where they ran through 2.3.x; an action that takes (req) still runs on the VM.
  • A model instance reads by key on the VM. user["_key"] and current_user()["_key"] answered “Cannot access property ‘get’ on User” once a handler ran on the VM, wherever the instance came from; the tree-walker always read them.

v2.4.1 — 2026-09-24

v2.4.0 was tagged and never published; everything listed under it ships in this release.

Tooling

  • The unwrap ratchet counts tests too, and one had grown it. A test added to src/template took the module one .unwrap() over its baseline, so the CI step that freezes those counts failed on every push — v2.4.0’s tag included, which is why nothing was built for it. The assertion reads the Result now, and the baseline locks in the parser’s drop from 130 to 128.

v2.4.0 — 2026-09-24 — tagged, never published

Mail

  • A Workspace mailbox is reachable again. A Google administrator can switch app passwords off for a whole domain, and by default now does — LOGIN then has no credential the server will accept, and the account is simply unreachable over IMAP. Imap.new(host, user, "", { "xoauth2": token }) authenticates with an OAuth access token instead. Minting it from a refresh token stays the application's job, so Soli never sees the long-lived credential. A refusal arrives as a continuation rather than a tagged NO, and is answered rather than waited on — a client that does not reply sits until the socket times out.
  • Listing an inbox no longer downloads it. fetch / fetch_uid ask for the whole message, which is right when you are about to read one and ruinous when you are drawing a list of twenty: a modest inbox costs megabytes and seconds, and the window sits still for all of it. fetch_headers / fetch_headers_uid ask for four header lines, so a list is kilobytes; fetch_headers_range(lo, hi) and fetch_headers_set("100:*") do a whole run in one round trip rather than one per message. Each row carries bytes (the message's real RFC822.SIZE, which a headers fetch cannot otherwise know) and clips, the attachment count read off BODYSTRUCTURE without downloading one. A sequence set is validated against the RFC 3501 grammar rather than interpolated onto the wire.
  • Mutating a message by UID. Every mutating verb took a sequence number, which is a position and moves whenever anything before it is removed — so a client holding UIDs had to spend a SEARCH UID n round trip first, measured at ~200 ms against Gmail, in which the application answers nothing. uid_mark_seen, uid_mark_unseen, uid_delete, uid_move and uid_copy are the same operations in one turn instead of two.
  • A message's third face is visible. text_body and html_body answer “the plain one” and “the HTML one”, and neither will ever return a text/markdown part — it is text like any other. Parsed messages now also carry parts: every text part with the type it declares, so a client can prefer the source a message was written in.
  • Sending that third face. Mailer.deliver accepts alternatives — an array of { "content_type", "body" } — and builds a multipart/alternative ordered least rich to most, so a reader shows the last part it understands (RFC 2046 §5.1.4) and a client that prefers Markdown can find it. Attachments still ride alongside, in a multipart/mixed wrapping the alternatives.
  • An answer reads as an answer. Mailer.deliver accepts a headers hash, which is what In-Reply-To and References needed — without them a reply lands in the reader as a new message. Both halves of the line come from the application here, so both are guarded: a name or value carrying CR or LF is header injection and is refused.
  • A named recipient is one address, not two. to and cc were handed over as bare strings, so "Ana <ana@x.io>" was written as To: <Ana <ana@x.io>> — one address inside another, which is not an address at all. They are parsed the way from always has been.

PDF

  • A document you can look at without a PDF viewer. pdf_preview(template, data) returns one base64 PNG per page — a thumbnail grid, a preview pane, an image asset — and pdf_preview_from_markdown and pdf_preview_response mirror their PDF counterparts. Size it with dpi (96 by default) or with width/height in pixels, which win over it; pick pages with the 1‑based selection pdf_pages already takes; pass out_dir to write the files and get their paths back instead. Nothing new is installed: the layout engine gained a second backend that paints its own draw model into pixels, so the preview is the layout, measured with the same font metrics, rather than a second renderer's guess at it. What it cannot show — a stationery letterhead, which is composited onto emitted PDF bytes; attachments, password, pdfa, sign — warns and renders anyway, so one options hash can drive both the PDF and its preview. Because dpi usually arrives in a request, each knob is capped, and out_dir writes through the same jail as file_write_base64.
  • And three times smaller as WebP. A page is flat colour and crisp type, which is what PNG is worst at: the invoice sample at 150 dpi is 165 KB as PNG and 51 KB at { "format": "webp", "quality": 90 }, a 320 px thumbnail 25 KB against 7.7 KB — with no visible ringing on body text. Encoding goes through libwebp, the encoder Image.format("webp") already uses, because the image crate’s own WebP encoder is lossless-only and gives most of that back. The rasteriser hands over raw pixels rather than a PNG, so nothing is encoded twice. png stays the default — lossless and universal is the safer thing to default to — and jpeg is there for completeness.
  • The documentation gallery stopped needing poppler. scripts/gen_pdf_previews.sh shelled out to pdftoppm at 150 DPI, to pdfinfo for a page count and to python3 to read a PNG header — three external tools, and a separate cargo build of the pdf/ workspace, to rasterise pages the engine had just laid out. It is a Soli script now, and soli is the only thing it needs. The markdown sample joins the gallery, having been excluded for having no template to feed pdftoppm.

Database

  • Transactions work again. with_transaction and every transaction { … } block died at the first statement with failed to begin: No tx_id in response. The id of a newly opened transaction was read out of a tx_id field; the server answers {"id": "tx:…"}. Both spellings are accepted now, and when neither is there the error carries the response body rather than naming a field that may not be the one missing.
  • db_query_hardcoded() stops returning nothing on a refusal. It threw away the HTTP status, so a 401 — which SoliDB answers with an empty body — came back as an empty string, indistinguishable from an empty result set. It reports the failure the way db_query_raw always has.
  • Keys and field names that could address something else are refused. A document key or collection name that is empty, . or .. is rejected (invalid document key / invalid path segment) by Model.find/update/delete and the raw SoliDB client. Field names longer than 128 characters are refused in order, find_by and hash where, on every adapter. like in a hash filter on includes uses a linear-time matcher, and _ matches one character, not one byte.
  • A warning for SQL connections that are not authenticated. Outside --dev, a Postgres/MySQL connection to a non-local host with sslmode disable, prefer or require (MySQL: below VERIFY_IDENTITY) logs a one-time warning recommending verify-full. The default is unchanged. See PostgreSQL.
  • A client-chosen where key no longer leaks. Query bind-variable names were interned into the process-wide symbol table, which is never emptied, so every distinct key a request supplied stayed in memory for the life of the worker. They are not interned any more. (instance.traverse(name) with a request-supplied edge name still interns it.)

Language

  • match no longer carries three pattern forms nothing can write. The AST declared ten pattern variants and the parser produced eight: 1 | 2 => … is a parse error, and so is Point { x, y } => …, whose branch sat behind a guard requiring the identifier to be followed by : — so the { it then looked for could never be there. All three were nonetheless handled, and maintained, in the parser, the tree-walker, the type checker, the formatter and the VM's compilation gate, which is how a feature that does not exist comes to look like one that does. Deleted rather than given a syntax: giving | a meaning is a language change and belongs in a proposal. The reachable spelling v: Type is untouched, and one consequence is worth having — the bytecode VM now compiles every pattern the parser can build.
  • Int.to_i and Float.to_f. Every type answered to_i — a string parses, a float truncates, null is 0, a bool is 0 or 1 — except the one type that already is an int, which raised Cannot access property 'to_i' on int. Float.to_f was the same gap mirrored. That is precisely backwards: the reason to reach for .to_i is that you do not know what arrived, and the only value it refused was the one already in the right shape — so every call site wrote (x ?? 0).to_i to defend against the case that needed no conversion at all. Both are total over the scalars now, and params["page"].to_i can be written once. The template engine already had both identities, so the same expression worked in a view and failed in a controller.
  • A call that creates a closure no longer leaks its locals. A closure or nested def captures the environment of the function it was made in, and that environment holds the closure — a cycle, so every such call kept its locals forever. When the closure does not escape (is not returned, or stored in a global, a collection or a field), the environment is now freed when the call ends.

Built-ins

  • A server no longer grows by megabytes per request through Retry. The embedded Retry class was evaluated into the very environment it was registered in, so its methods held that environment and the environment held them — a cycle nothing freed. A fresh interpreter is built for every named-scope call, validator and user method on a primitive, and each one left a whole builtins registry (~350 KB) behind. The class is now evaluated once per thread and shared, and a test holds that dropping an interpreter frees its globals.
  • Markdown.to_text(md). The third face of one source: to_html for readers that draw, this for readers that do not. Stripping the tags off the HTML is not the same thing — it loses a list's bullets and, worse, every link's address, leaving the words of a link and no way to reach it. Headings keep their words, list items keep a marker, a link becomes text <url>, a quote keeps its > , a fence keeps its lines verbatim.
  • DateTime.now() carries a subsecond. It was timestamp() * 1_000_000_000, so every instant it made had a zero subsecond: millisecond() answered 0 for all of them and a duration measured between two now() calls could only come out as a whole number of seconds. Anything timing itself in Soli was timing nothing. utc() beside it was always right.
  • An image's format comes from its bytes. Image.new inferred it from the file extension alone, so a file named by content hash — which is what a cache of downloaded images looks like — failed to decode however ordinary a PNG it was. The header is sniffed now, as every other reader of these formats does; the extension is not ours to control when the file came off the network.

Web

  • A 404 from a wildcard route stops dropping the session cookie it owes you. The route lookup misses in two ways — nothing matched the path at all, or a wildcard like docs#* matched but no action answered to it — and only the first re-emitted the session cookie. With the default session drivers nothing shows: the id is unchanged, so there was nothing to emit either way. With SOLI_SESSION_DRIVER=cookie the whole session lives in the cookie, and the driver replaces it whenever the incoming blob is invalid or expired — so the second kind of 404 swallowed the replacement, and the browser went on presenting the dead blob on every subsequent request until some other page happened to reset it. There is one miss now, and it emits the cookie and writes the access-log line the wildcard miss also skipped. See Sessions.
  • The in-memory session store stops growing forever. Expired sessions were never actually removed; they are now swept every 1000 creations or 30 s, and the store is capped by SOLI_SESSION_MAX_IN_MEMORY (default 100000, 0 = unlimited). Past the cap the least-recently-used sessions are evicted, which logs those users out.
  • Realtime cleans up after itself. An SSE subscriber that disconnects from a quiet topic is freed instead of being held until the next publish, and a WebSocket channel join arriving after the socket closed is ignored.
  • The form builder cannot be redirected by an app helper. form_with and its builder now evaluate in their own environment, so an app helper named h or attr no longer replaces the escaping the builder relies on. See Forms.

Security

  • cargo audit passes with no vulnerability waived — the first time this repository can say that. Of the eleven advisories it carried, two were real vulnerabilities rather than unmaintained-crate notices: quick-xml below 0.41, a quadratic duplicate-attribute scan and an unbounded namespace-declaration allocation, both denial of service and both reachable only through Spreadsheet.excel* on an untrusted spreadsheet. They were waived because they could not be fixed — every release of calamine and umya-spreadsheet resolved quick-xml below 0.41, and forcing it with a patch did not compile, because both call the older API. Their latest releases require quick-xml ^0.41, so the manifest moves to them and the waiver is gone. quick-xml 0.31 leaves the dependency graph entirely, and so does image 0.24, a second copy of an already-present dependency that came in behind the spreadsheet writer. The remaining ten waivers are unmaintained crates, each with its blocker written down.
  • Framework-reserved paths never reach your routes. A request under /__soli/, /__solidev/, /__dev/, /__coverage__ or /__livereload (exact, /__livereload/…, /__livereload_ws) that the framework does not itself serve is now a 404. Those paths used to skip the CSRF gate and fall through to the app’s routes, so a catch-all /:slug answered POST /__dev with no Origin check. /_health, /_ready and /_metrics lose their CSRF exemption — they are GET-only anyway. See Routing → CSRF.
  • --dev endpoints answer only a local Host. The dev-bar diagnostics, /__dev/*, the inbox, request replay and the REPL token in dev error pages now require localhost, *.localhost, an IP literal, or a host in SOLI_APP_HOSTS — a DNS-rebinding page could otherwise drive them through the developer’s own browser. A name like mymac.local or myapp.test must be added to SOLI_APP_HOSTS. The jobs dashboard is credential-free only from a loopback peer with a local Host (otherwise the configured Basic/Bearer credentials, 404 if none), and the inbox clear and replay POSTs must be same-origin. See Production security defaults.
  • Request bodies are charged to the upload budget as they arrive. A request reserved the full SOLI_MAX_BODY_SIZE before reading a byte, so a crowd of tiny bodies could exhaust SOLI_MAX_INFLIGHT_BODY_BYTES; it now starts at 64 KiB and doubles as the body grows. New SOLI_BODY_IDLE_TIMEOUT_SECS (default 10): a body that stalls between frames gets 408. A transport error mid-body is 400 (was 413). Static files over 1 MiB are streamed from disk instead of read into memory. See Server Hardening.
  • /_metrics behind a proxy needs its token. Without SOLI_METRICS_TOKEN, the endpoint answered any loopback or private-range peer — which, behind a reverse proxy, is every request. It now refuses (404) a request carrying X-Forwarded-For / X-Real-IP / Forwarded, or any request while trust_proxy is on. Deployments behind a reverse proxy must set SOLI_METRICS_TOKEN. See Observability.
  • HTTP/2 connections are kept honest. h2c connections send keep-alive pings every SOLI_H2_KEEPALIVE_SECS (default 30, 20 s to acknowledge) and close after SOLI_CONN_IDLE_TIMEOUT_SECS (default 60) without activity. The undeclared-Host warning is logged once per host (at most 32) instead of on every request.
  • 5xx pages stop repeating the internal error. A custom errors/5xx template now receives a generic message (“Internal Server Error”) in production; the real error is still logged. 4xx messages are unchanged. See Error Pages.
  • Cookie sessions have an absolute lifetime. A cookie-driver session that kept being written never expired, and neither did a stolen copy of it. SOLI_SESSION_MAX_LIFETIME (default 2592000 = 30 days, 0 disables) caps its age from issue however active it is; session_regenerate() restarts the clock, and a cookie issued before the upgrade counts from its iat. See Sessions.
  • Outbound fetches the app did not write are SSRF-guarded too. PDF remote images now go through the guarded HTTP client (DNS-rebinding safe, every redirect hop re-validated, bodies over 20 MiB rejected), and the PAdES sign.tsa URL is checked (private/loopback refused, reply capped at 1 MiB). The blocklist gains 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4 and IPv6 forms that embed a blocked IPv4 — NAT64, 6to4, Teredo, IPv4-compatible. See HTTP.
  • Crypto.modexp caps its operands — modulus and exponent at most 8192 bits, base at most 16384 — so a request-supplied operand cannot pin a worker for minutes.
  • Sharp edges, now written down. Crypto.totp_verify accepts ±1 step and has no replay protection — remember the last accepted step per user and rate-limit. jwt_verify checks aud only when you pass an expected audience. Crypto.pkcs1_unpad is not constant-time: verification and interop only, never expose its errors from a decryption endpoint. strip_html is a naive tag stripper, not a sanitizer: escape its output, and use sanitize_html for untrusted HTML. See Crypto.
  • One client can no longer hold the whole upload budget. New SOLI_BODY_BUDGET_PER_IP_BYTES caps the share of SOLI_MAX_INFLIGHT_BODY_BYTES a single client may have in flight — by default a quarter of it (never less than one SOLI_MAX_BODY_SIZE, 0 disables). Over it, the same 503 with Retry-After: 1. The client is the TCP peer, or the right-most X-Forwarded-For with trust proxy on; IPv6 counts per /64. Behind a proxy without trust proxy, every client shares the proxy’s one quarter — turn trust proxy on, raise the variable, or set 0. See Hardening.
  • SOLI_TRUSTED_PROXIES now reaches the origin checks. The CSRF Origin gate, the WebSocket and live-reload upgrade origin checks and the --dev same-origin check ignored the trusted-proxy list and believed X-Forwarded-Host from any peer once trust proxy was on. They now check the list against the real TCP peer, like the rest of the request path.
  • soli.lock pins what a dependency contains, not only which commit. soli install / add / update record a SHA-256 of each git or registry dependency’s extracted files on a #@integrity <name>|<sha>|sha256-… line — trust on first use, per resolved revision — and refuse an install whose files hash differently (“Integrity check failed for module … Refusing to use it.”). A mismatching fresh download is deleted; drop the #@integrity line to accept new content. A mismatching existing cache is kept; delete it to re-download. Path dependencies are not hashed, and older soli versions simply ignore the line. A download that fails half-way no longer leaves a cache directory that the next run takes as installed. See Modules.

Dev tools

  • Hot reload picks up app/mailers/ and app/policies/. Four directories load together into a worker — models, services, policies, mailers, in that order, because a policy refers to a model and a mailer to both — and that list had been written out twice: once for the worker's initial load, once for the reload, where it was a directory short. So editing a mailer signalled a reload (the watcher maps it to the models signal, and says so in a comment) and the worker reloaded models, services and policies, leaving the old mailer class resident; you saw the previous version until you restarted. app/policies/ was the same defect from the other end — the worker reloaded it, but the watcher never watched it, so a policy edit on its own signalled nothing at all, and it only ever appeared to work because editing a model dragged it along. The list is one function now, called by both, and the watcher covers all four. See Live Reload for what is watched and what each change reloads.
  • The flamegraph no longer freezes the page it is profiling. It drew every span twice — a chart rect and a list row — and a request that walks a few hundred records produces thousands: 3.4 MB of markup on a real dashboard, laid out on every load whether or not anyone opened the panel. It now draws the 300 heaviest spans, chosen by duration so the cut never hides what you are looking for, and says so in its header; SOLI_DEV_FLAME_MAX moves the bound, 0 removes it. trace.json stays complete — above 64 KB it is served from /__solidev/trace/<id> instead of being inlined. See Debugging.
  • soli check learns the runtime's global namespace from the runtime, and stops calling working code undefined. On a 247-file application it reported 237 errors, all false — which is how a project comes to leave it out of its verification gate, and a gate one has learnt to ignore protects nothing. 140 of them named something the interpreter installs: describe and res_body, RateLimiter, middleware from the routing DSL, Mailer, form_with. The checker's list of engine classes was a second list beside register_builtins, drifting from it by construction; it is derived now — the builtins are registered into a throwaway environment which is then asked what it holds, the six preludes the runtime evaluates as Soli source are parsed for what they declare, and a class global carries its native verbs, so I18n.cache_table resolves while I18n.tarnslate is still an error. Two sets stay apart and are seeded per file: the test DSL, for one soli test would run (a describe(…) in a controller is a real error), and the request scope — req, params, session, render, redirect — for a file under app/, config/ or stdlib/, so that a loose script calling render still fails as it did. Then four narrower ones, each a case where the checker's answer was not what the language does — a method with no return annotation was typed Void, so every use of what it returns was an error; a class whose parent is in a sibling file or installed by the framework looked like a root with no inherited members, as did a module; let x = nil was Null rather than "nothing known yet"; and writing a key into a hash was checked against the value type of the literal that built it. The pass over those 247 files is clean, in 0.4 s, and what still fails was verified case by case — a typo'd variable, a typo'd verb, a bad annotation, three arguments for two parameters, an unknown member on a class whose whole ancestry is in the file. See Type checking.
  • soli check and soli lint know the EUI builtins — and a test now stops the next family being missed. router_eui, eui_render, eui?, eui_wake, eui_notify, eui_asset, eui_font, eui_icon, eui_name, eui_stats and eui_capabilities were registered at runtime and declared to neither the type checker nor the linter, so soli check answered Undefined variable 'router_eui' and smell/undefined-local flagged every call — on an application written by soli new --eui itself. sse and stream were in the same state, next was known to the linter and not the checker (which is why it only ever worked inside a server handler), and permit was the reverse. Fifty-odd ordinary builtins a controller calls without thinking — cache, forbidden, json_stringify, password_hash, md5, url_encode, slurp, broadcast, csrf_token — were missing from the lint list too.
  • The same defect, fixed for the third time, is now pinned. Ten pdf_* builtins, then Image/File/Trusted, then this — and each time a user found it, because a builtin that works and only the tooling refuses makes no noise. The build now enumerates the runtime environment and holds every global against both lists, as a ratchet: a name that is not in the recorded baseline fails, and a name in the baseline that has since been registered fails too, so the file can only shrink. The baseline is not a list of bugs — most of it is DSL that is only legal inside a spec, a model body or a migration, and render/redirect sit in it as a decision, with the reason written beside them.
  • A coverage line is charged to the file that carries it. Every executed line was charged to the entry script — the spec, or the request — so every hit from a called function landed on the caller's file and the file that actually holds the code came out empty: a controller whose three routes are tested and whose response bodies are asserted line by line reported 2.7%, with lines counted uncovered that run on every request. On a real application (187 spec files, full browser suite) 66 files change their number: the ones that would not move despite tests plainly exercising them go up, and three controllers drop below 90% because they had been living off their neighbours' hits. That is the attribution becoming honest, not worse.
  • A body run by a fresh interpreter keeps its file. Seven places build a new interpreter to run a user function's body — model scopes, receiver-bound methods, custom validators, validation conditions — and a new interpreter has an empty call stack, so none of those lines were attributed anywhere. A scope(...) called hundreds of times stayed at zero hits in every model in every project, and the small file that declares it looked far less covered than it is.
  • A file shared by symlink is counted once. An application that shares code by symlink saw every shared file listed twice — once at the path it reads, once at the path the link resolves to — one of them at 0.0%. Coverage now keys on the path the application believes it is reading.
  • The file name comes before the bar in the soli test dashboard. A worker row laid down a 14-cell bar whatever the width and gave the name what was left — its four-character floor everywhere under 37 columns, so a narrow pane showed dos… and the row ran past the terminal without saying so. The row is composed by priority now: the counter, the elapsed time and then the bar are added only if they fit, and dropped rather than take the name below fourteen characters. The aggregate bar sizes itself too — fixed at 30 cells it wrapped under 70 columns, and a wrapped line breaks the cursor rewind, so the dashboard stacked on every repaint instead of repainting in place. COLUMNS now outranks the terminal ioctl, so a pane whose width cannot be asked for can declare it.
  • smell/closure-cycle, a lint rule for the leak you cannot see. A closure stored on the instance — this.x = fn…, @x = |y| …, this.h["k"] = fn… — captures the method environment that holds this, so the two keep each other alive forever and every such object leaks in a long-lived worker. Store a method name, or pass the closure per call. See Linting.
  • A new app's CLAUDE.md lists what Soli already does, so an agent stops rebuilding it. The full reference was already copied into every app under docs/, but nothing in the root guide pointed at it by capability — an agent asked for two-factor auth never found Crypto.totp_verify, filed deep in docs/builtins.md under Password Hashing, and wrote TOTP by hand. The guide now opens with a “Built in — check before you write it yourself” table (2FA, OAuth, uploads, PDF, jobs, rate limiting, search, push, i18n, …) mapping each need to its builtin and its doc, and a test fails the build when a file added to www/docs/ is not referenced there. Existing apps get it with soli update docs. TOTP also has its own section in the builtins reference now, with totp_generate, totp_uri and how to generate a Base32 secret; the examples that called a non-existent QRCode.encode are gone. See AI agents.
  • Per-request logs are bounded. The query, HTTP and KV logs keep at most 10 000 entries per request, WebSocket event or background job, and are reset per WebSocket event and per job instead of accumulating across them.

Performance

  • The scaffold’s def index runs on the VM. Zero-argument controller actions were always handed to the tree-walker; they compile now.
  • Less copying on the response path. HTML responses are no longer copied when no script is injected, and the ETag hash is faster — ETag values change once after upgrading, so clients revalidate once. Static files: the public directory is canonicalised once and the asset cache is consulted before the filesystem.
  • Database plumbing without contention. The SoliDB JWT no longer takes a global write lock per query and a token refresh no longer blocks other workers; the DB registry is not deep-cloned per query; SQL adapters remember the tables they created instead of issuing CREATE TABLE IF NOT EXISTS before every write; SQLite has a statement cache; the solidb/solikv session drivers load the session once per request.
  • Smaller wins. The regex cache hands out shared compiled regexes behind a thread-local front cache. In production a template lookup that failed stays cached as missing until reload. SOLI_NAV, SOLI_PREFETCH, SOLI_PREFETCH_TTL, SOLIDB_HOST, SOLI_APP_HOSTS, SOLI_DISABLE_CSRF and SOLI_CSRF_TOKENS are read once per process — changing one now needs a restart.
  • Queued requests are woken, not polled. A request waiting behind busy workers re-checked for a free slot every millisecond; it is now woken the moment a worker frees one.

EUI

  • A shared element: one thing on two pages. shared_element(name, node) in the scaffolded catalogue. Give the same name to the node that is leaving and to the node taking its place, and the arriving one flies out of the box its partner had — a row's avatar becoming a header's avatar, a thumbnail becoming a hero. Both ends are boxes the client already laid out, so nothing is laid out again for it. The protocol has had the byte for this since the format had a spare one, and three things stood between it and an application that could use it. The offset was between the two corners while the transform scales about the node's own centre, which put the element half the difference in the two sizes away from where its partner stood — always, since a shared element the same size on both sides is not one. Departures were read off a list that never contains them: a page swap names the page, and the thumbnail is inside it. And the boxes were keyed by the bare name, which an island's own table would have collided with. A name that resolves to nothing is still the ordinary case and not an error — a panel is built and torn down as it opens — which is also the one way to get this wrong silently, so EUI_TRACE=1 prints a line per pair, resolved or not, and says why. See Styling.
  • A socket that breaks is no longer an application that ended. A client whose connection dropped offers back the sixteen bytes its Welcome named, with the sequence of the last batch it applied. When they name a session still here, the answer is Welcome{Resumed} and only the batches that client missed — no connect, no resync, no whole tree. The reader keeps their scroll, their focus and what they had half-typed; before this, every dropped wifi hop was a fresh mount that returned them to the top. The handle is minted per session rather than taken from the cookie, since two tabs of one reader share a cookie and must not share a session, and it is not sufficient on its own: a resume also checks the cookie and the component, or sixteen bytes would be enough to be handed somebody else's tree. A client too far behind is refused rather than half-filled. Two minutes of grace. See Overview.
  • An event's payload is checked against the shape its kind declares. The protocol asks a server to verify three things before a handler runs — the node exists in the tree it last sent, it carries a handler of that kind, and the payload is the shape the kind declares. Soli did the first two. A click carrying a string, a null, one coordinate or a thousand reached a view handler exactly as a pair of coordinates would, and what happened next was the application's problem. All three are checked now, cheapest first. An event that fails any of them is dropped and not an error: it may have arrived from a client one render behind, naming a node that existed a moment ago, and ending the session there would cost somebody their application for a mouse movement — while making one malformed frame an attack on every reader. EUI_TRACE=1 says what was dropped and why. See Events.
  • A view can be served as one render, with no session behind it. A socket costs this server a session per reader — the instance, the four interned tables, and the previous tree they diff against — which measures at 50–60 kB for somebody who is only reading, against 4 799 B of page. For a documentation page that is the wrong shape, and the socket buys nothing, because nothing on such a page changes unless the reader changes it. Six hundred distinct renders of one now move resident memory by four kilobytes.
    • format.eui(fn() eui_render(tree)) in respond_to: a page and its already-resolved form are two representations of one resource, so they share a route, a URL and its params. A browser gets the page; a client asking for application/vnd.eui.frames gets the render.
    • eui_render(tree) returns an ordinary response with an ETag over the body and a 304 on If-None-Match. The encoder is built, used and dropped inside the call — no instance, no registry entry, nothing to clean up — because an action is already on a worker with an interpreter. eui? answers the same question for a controller that would rather branch itself.
    • A component with no route of its own takes router_eui(..., {"static": "public, max-age=60"}) and is fetched from GET /_eui/view/<component>. Refused alongside {"session": "required"}, because a static view is rendered for nobody.
    • Rendered for nobody means exactly that: no session, no cookie, no locale, and no same-origin check — a resource a CDN is meant to hold cannot have one. Offering a view this way is promising that rendering it is a read. See Serving a page with no session.
  • A browser that opens an EUI address gets a page, not a 404. An application that serves EUI and no HTML for a path answered browsers with an error page. It now answers with a short page saying what the address is and how to open it — a fallback, not an interception, so a route the application defines always wins. The address it prints comes from the forwarded host, since behind a proxy Host names an upstream nobody else can reach.
  • A session can start from the tree the client already has. A page fetched as one render opened no socket until something needed the server — and then mounted itself again, discarding the tree, the layout, focus and every scroll offset, so a reader partway down was returned to the top. The client now offers the hash of the batches it holds and Soli compares it against the connect it was going to render anyway: on a match it sends a Welcome and nothing else, on a miss it sends the frames it just rendered. One render either way, nothing for the application to write, and the protocol goes to version 5 — both halves ride on tag bytes that were previously refused, because neither frame can grow a field.
  • Islands: one corner of a page can be live while the rest is a cached render. A node carrying island — an absolute path on the same origin — takes its content from a session of its own, so a documentation page with a comment count no longer costs every reader a whole session. Its own children show until that session speaks, which is what makes an old client, a failed session and a stale cache degrade the same way: out of date rather than missing. A session address may now carry a query, and it arrives as connect params — ?for=1042 is how two islands of one component say which of them is being rendered. Nothing was added to the wire for any of it.
  • A view can move the caret. focus_to: true emits Op::Focus, the op the protocol has had all along and nothing was sending. autofocus cannot cover it: a client applies that only when focus is not already where it belongs, so that a batch arriving mid-Tab does not yank the viewer back — which makes it useless for a view that opens a field, and a search bar opening over a page could not be typed into. Like scroll_to, it is done rather than held: asking twice is not asking again, and a view that stops asking is not asking for focus to be taken away.
  • transition gains "slower" and "slowest", for ambient movement — a meter settling, a background easing between states — where "slow" is still an answer to something the viewer did.
  • A vertical VU meter is the mirror of the horizontal one. It was not: a segment took no growth vertically, so the bar came out the height of its contents (58 px) while the legend beside it stretched to 96, and no mark stood against the segment it names. Both now share the length the strip is given, and vu_meter takes labels so a reading that is neither one channel nor a stereo pair — bands of a spectrum, a channel per voice — can name its strips. Without a name each one announces itself to a screen reader as “Level”, five times over.
  • An event begins by emptying the per-request logs, the way a request does. The dev bar's logs — queries, HTTP and KV calls, phases, views, spans — are per-worker buffers that an HTTP request empties on the way in. A socket event never came in that way, so on a realtime worker they only ever filled: in --dev the flamegraph records a span per function call, and an EUI view redrawing ten times a second grew that buffer by close to a megabyte a second until the window went slow, then froze. It read like a leak in the application, and nothing the application held was growing. Measured on one page against one daemon: 0.85 MB/s with --dev before, a minute of settling and then flat after. One function, forget_request_logs, now stands at both doors.

v2.3.7 — 2026-09-17

Web

  • A page that ships its own confirmation dialog can say so. data-confirm is not a reserved attribute, and the nav script took it over in 2.0.3 — rightly, since it replaced an onclick="return confirm(...)" escaped for JavaScript and not for HTML, which was a stored XSS in the most ordinary “Delete <title>?” button. But an application already using that attribute for its dialog found a native box opening on top of it, on the click, before its own submit handler ever ran. That is not a matter of taste: window.confirm blocks the renderer until someone answers, and nothing answers in a driven session — ten browser specs across six files stopped returning, without failing, without a word. The script now dispatches a cancelable soli:confirm on the element before the native box: a page that handles confirmation itself calls preventDefault() and takes over, and a page with no listener keeps the box it always had.

Dev tools

  • A read timeout is no longer reported as a read that failed. The specs' HTTP client sets a 10s read timeout so a mute server cannot pin the worker. When it tripped the kernel returned WouldBlock — “Resource temporarily unavailable” — and the loop announced read response failed, never mentioning a timeout. A response taking eleven seconds on a loaded machine therefore became a spec failure whose message pointed the wrong way, and days went into looking for machines out of resources that were not. The socket timeout goes back to being a wake-up rather than a verdict, a 30s overall deadline decides, and the message names the method and the path when it trips.

v2.3.6 — 2026-09-16

Dev tools

  • A native dialog no longer wedges the browser driver. alert, confirm, prompt and beforeunload block the renderer until someone answers, and nothing in a driven session ever does: the command in flight never got its reply, nor did any command after it, so soli test --browser simply stopped producing output — no failure, no diagnostic, six spec files running half an hour without returning. The event had been arriving all along, filed among the ones nobody reads. It is handled inside the read loop now, before the message is even matched against the pending command id, because that is the only place holding the socket while the page is frozen. The dialog is dismissed rather than accepted — a confirm guarding a deletion must not be answered “yes” by a driver — and it is recorded as a page error, so assert_no_page_errors shows it instead of letting a spec pass over a screen nobody can drive.
  • The diagnosis was worse than the fault. The driver's socket carries its own read timeout so a wedged browser cannot pin the worker, and when it tripped the kernel said EAGAIN — which the loop announced as lost the browser connection. That sent a whole afternoon looking for a machine out of resources; the machine was idle and a dialog was waiting. A read timeout now goes round the loop again, the command deadline stays the only judge, and its message names what to look for.

v2.3.5 — 2026-09-16

Performance

  • The job poller stops asking. Its interval was the only way it learned about a job, so an idle soli serve cost two round-trips per poll_ms forever — a list of crons whether or not the app declared one, and a claim whenever a worker slot was free. At the default that is two requests a second per process, and a machine with a few dozen apps open made it the dominant load on the shared database: a measured ~17% of a core on SoliDB, against nothing at all for an identical instance holding the same data. The poller now waits to be told, over a changefeed subscription to _jobs and _cron_jobs, and the interval becomes a backstop — SOLI_JOBS_IDLE_POLL_MS, 30 seconds by default, while a subscription is live. The socket is a hint and never the source of truth, so the poller keeps its own timer regardless: a dropped subscriber loses latency, never a job.
  • Deleting a row no longer looks like work arriving. The wake filter read the document body, and a delete carries none, so it fell through every check — and a retention sweep issues one delete per row. On two apps whose job table had grown to five and six figures, pruning woke the poller once per deleted row, each wake costing a query that could only come back empty.

EUI

  • A meter, and the event that feeds it. level arrived in protocol version 3, and a client that settled on 2 cannot send it. Refusing at the handshake would have been the wrong trade — a capability is declared before anyone knows which version the far end speaks, so it would turn a working application into a refused one for the sake of one widget. The gate sits on the handler instead: an older client simply never has level wired, and gets an application that works and a meter that does not move. The catalogue gains vu_meter and the vu_strip / vu_segment / vu_scale / vu_zone it is built from, plus a scene builder for the node kind that landed in 2.3.2.

Web

  • The image-transform ceiling is configurable. w, h, thumb, square and crop were clamped to a hard 1000 px. The cap has to exist — anyone who can reach the endpoint can append ?w=99999&h=99999, and it is the only bound on the allocation — but a fixed ceiling is wrong for an app that genuinely serves larger images. SOLI_ATTACHMENTS_MAX_DIMENSION makes it the default rather than the limit. Set it to what the app actually serves, not to a number chosen for headroom: raising it raises the largest allocation a crafted URL can ask for. A missing or nonsensical value falls back to the default instead of removing the guard.

Dev tools

  • soli test tests/browser --browser works. It died on .env.test file not found at 'tests/.env.test' before a browser was ever considered. The runner located the app root by counting parents — one for a directory, two for a file — on the assumption that specs sit exactly one level under it. Browser specs do not: they are recognised by a path component named browser, so they have to live in tests/browser/, and the layout the feature requires was the one the resolver could not read. Any nested spec directory failed the same way, with or without the flag. The root is now found rather than counted.
  • A tutorial for EUI. A Native Window in 80 Lines of Soli builds a notes app from soli new --eui to a window — handler, view, motion, a local handler, then assets, capabilities, scenes and eui_stats.

v2.3.4 — 2026-09-15

Dev tools

  • soli test counts tests, and counts them live. The runner knew how many files had run and how many assertions they had fired, and both numbers only moved when a file finished — a spec that runs for twenty seconds contributed nothing to the display until it ended, because the assertion counter was a thread-local the painting thread could not read. Assertions now also feed a process-global counter, so the bar advances during a file; and each test(...) block is counted as it ends, which nothing did before. The bar reads 41/158 1 204 tests · 6 018 assertions, calls out failing tests in red as soon as they fail, and the summary says which unit each line counts: 156 files passed, then 1 204 tests, then 6 018 assertions.
  • New lint rule idiom/prefer-to-s. x ?? "" means “render this, and render nothing when it is nil”, which is what .to_s says in one call. It is also the more honest version when the value is not already a string: count ?? "" evaluates to the number when there is one and to "" when there is not, so what it yields changes type with the data. A real fallback — name ?? "Guest" — is left alone.

v2.3.3 — 2026-09-15

Tooling

  • A lockfile written under a local [patch] is not the one CI resolves. The patch points the EUI crates at the checkout next door and is untracked by design, because a path dependency in the manifest breaks every build that has no sibling. But cargo records a patched crate as a path dep with no source, so the lockfile left behind describes a resolution no other machine performs, and every job but audit refused it. Regenerated with the patch removed, so it names the pinned revision again. v2.3.2 was tagged and never published for exactly this reason — its own CI was red, so the release job never ran and no binaries were built. This release carries everything 2.3.2 was meant to.

v2.3.2 — 2026-09-14 — tagged, never published

EUI

  • The Welcome frame names the version both ends speak, not this server's own. A server built against a newer EUI would have refused every older client — including for applications using nothing the new version added — because the client checks that version against its own and stops if the server names a higher one. It answers min(hello.version, PROTOCOL_VERSION) now, which is what a negotiation is. This went unnoticed while EUI stayed at version 1; it would have bitten on every bump after that, and the first one is in this release.
  • scene joins the node kinds a view may write — a 3D picture drawn by a shader the application wrote, rendered into a target of the client's own and composited as one quad. Its shader and mesh travel the verified asset path a picture's src already takes: a hash on the wire, fetched from the origin, checked against its own name before anything decodes it. Neither is inspected server-side — the client validates both in its confined worker, the module against a verifier that proves it terminates before compiling it, the mesh against its own vertex count, which is the one bounds check no GPU driver performs. uniforms is the author's half of the uniform block: at most eight numbers, zero-padded, refused if they are not finite. The other twenty-four floats belong to the client, which is why a server never sends a camera and so can never send a degenerate one. It needs a client that understands the kind, so an application asking for the capability advertises protocol_min: 2 and is refused at the handshake, with a reason, rather than mid-session on a batch it cannot read.

v2.3.1 — 2026-09-14

EUI

  • soli new <app> --eui no longer writes a slider that can end the session. The catalogue gated the track's pointer_move handler by declaring it only while a drag was live — the one shape the spec rejects, because an event already in flight then names a handler the server has since removed, and the session goes with it. Measured on a split pane: 167 of 429 events dropped. slider and range_slider now declare a track and one change handler that never leaves the tree. The client resolves the whole gesture and reports only when the quantised value moves, so a drag costs one event per step crossed rather than one round trip per pointer sample; the builders keep no arithmetic and no width to invert an offset against, since the only width that was ever true is the one the client laid out. An application generated by this release needs a client that understands track.
  • A light-dismissable dropdown, and a manifest that says what a bare origin opens. dropdown takes an on_close, and a press outside the widget reaches the overlay as blur without the server being asked.

v2.3.0 — 2026-09-14

EUI

  • Going back. back() in a local handler asks to go back the way the platform's own gesture does, and back becomes an event a view may declare a handler for. It takes nothing and returns nothing: what it asks for is the server's to grant, so a back button and a swipe from the edge cannot come to mean different things.
  • Motion. A node says how it moves — fade, leading, trailing, top, bottom, scale, paired — and animation becomes a bit set rather than one name. One name still works and every existing view keeps meaning what it did; two are written as a list, ["enter", "exit"], because a page has to say how it arrives and how it leaves while it is still there to say it.
  • Bytes that were never a file can be drawn. An image src was a path under public/ or app/assets/, which an attachment is not. eui_asset(bytes) puts bytes in the content-addressed store and answers {"asset": "<hash>"}, which a src now accepts beside a path; read_upload reads an attachment back whatever service holds it, and a file_upload event carries a content_type derived from the name.
  • The scaffolded catalogue roughly doubles — navigator, nav page and back button, context menu, command palette, combobox, password/file/drop fields, attachment card, tag field, one-time-code field, split button and panel, expandable row, tree table, avatar group, toggle group, popconfirm.

Server

  • A request can be routed to an application by Host. State that used to be one-per-process — the database layer, the model registry, the session config and store, the realtime registries, routing and schema, SMTP, trusted proxies, rate-limit buckets, the KV connection, the header policy, streaming and attachment state — is now one-per-application. Nothing changes for soli serve, which mounts one application and answers every host and no host at all; mounting several is not wired up yet, and the routing rules land first because that is the part worth having under test.

Formatter

  • Two brackets no longer meet. The lexer reads [[ as the opener of a raw string unless the byte after it is a digit, - or [, so a nested array broken across lines silently became a string and the file stopped parsing. The rule now lives in the printer, where it holds for every arm that can put one bracket after another.
  • A guard whose value wraps is recognised. The scan for a postfix if stopped at the first newline, so ]) if cond at the end of a wrapped value was invisible: pass 1 printed a block, pass 2 collapsed it back. It now reads past a newline while brackets are still open, and skips # comments so one cannot lend its if to the scan.

Tooling

  • soli test stops paying production key-stretching for fixture passwords. A suite pays Argon2's RFC 9106 cost — 19 MiB, two passes, ~20 ms — twice per authenticated test: once creating the fixture user, once verifying at login. On one real application, 1 013 logins and as many fixture users, that was 38 of the 106 seconds the run took. The runner now sets SOLI_ARGON2_FAST=1 for itself and the servers it spawns, dropping new hashes to 4 MiB and one pass; the same suite finished in 88 s and the average login went from 62 ms to 29 ms. It changes hashing only — argon2_verify reads the cost from the stored PHC string, so a production password keeps its full price however the variable is set, and one database may hold both kinds. Only 1 or true switch it on.

Web

  • <% unless %> is a block in a template. The template parser knew five block openers — if, for, content_for, form_with and a component — and anything else went to the language parser as a statement complete in its own tag. Since a block-form unless there does not insist on its end, <% unless c %> parsed as an empty one, silently: the body was dropped and the template's own <% end %> then failed with Unexpected 'end' outside of block, naming a line two below the mistake. It is now a real block wherever an if already was — top level, inside a loop, a branch, a content_for, a form_with or a component — with else but not elsif, as in the language itself.

Performance

  • Views no longer see the test-only builtins. The template engine registered its environment with test helpers included, so visit, click, assert_eq and the factory and mock helpers resolved inside a rendered .html.slv in production — names ordinary enough that register_builtins already refuses them everywhere else in serve mode. A template that called one was relying on an oversight; nothing in the framework did. soli test is unaffected.
  • A worker thread builds the builtins once, not three times. The ~500 bindings register_builtins defines depend on no application, yet every worker built the whole registry three times over and kept all three alive — for the interpreter’s globals, the template engine’s environment, and the view helpers’ closure. One registry per thread now serves all three as their enclosing scope, so an application binding still shadows it exactly as before. This does not show up in RSS: five alternating A/B pairs at SOLI_WORKERS=16 put the difference at 0.4 MiB against a per-sample spread of ~12 MiB. A registry is a few hundred KB, not the megabytes first assumed. It is kept for the redundant boot work it removes, not for a memory win.
  • scripts/mem-probe.sh measures what a soli serve process actually costs. ps -o rss= cannot separate a process’s own heap from the file-backed pages every soli process shares, so it reads Pss_Anon plus SwapPss — residency alone makes a process read as smaller the more the machine swaps. Samples run with the allocator purging promptly, each point is a median with its range (transparent huge pages moved anonymous RSS by tens of MiB between identical runs), and --ab alternates two binaries in one session, which is the only comparison worth trusting.

Fixes

  • The test suite no longer aborts partway through. Two tests drive the interpreter to its call-depth cap, on libtest’s own 2 MiB thread. With debug_assertions off the cap is 256 frames, and the CI profile builds without fat LTO, so frames are larger than in the release build the number was measured against — the recursion hit the end of the stack first, and a stack overflow aborts without unwinding, taking the test process and every test ordered after it. Nothing in Soli runs interpreted code on a stack that small, and the runtime is correct where it does run: unbounded recursion answers call stack too deep (256 frames) and exits 70. Both tests now run on a worker-sized thread, so they test the cap rather than the harness.
  • A database test stopped depending on SoliDB being at localhost:6745. Its fixture hard-coded that host and the default database — the fallbacks used when SOLIDB_HOST and SOLIDB_DATABASE are unset, not the values compared against when they are set — so pointing SoliDB anywhere else read as a per-connection host mismatch. The sibling test that asserts such a mismatch is refused was passing for the wrong reason. The fixture now reads the same environment with the same defaults.

Docs

  • The EUI reference is its own section. One page became eight under /docs/eui/* — overview, styling, events, assets, and the catalogue split across layout, input, content and internals — with its own sidebar group; /docs/core-concepts/eui redirects so existing links still land. It now documents what the last cycle added: the 33 colour roles in a table (series.* included), all 29 event kinds with what fires them, and eui_wake, scroll_to and position: "pointer". The catalogue went from 79 entries to 175 — every builder in eui_builders.sl, each with a real call site quoted from the demo app or the catalogue itself rather than an invented sample. Helpers are marked as implementation detail.

v2.2.1 — 2026-09-12

EUI

  • eui_wake(component) renders every other live session of a component, now. A window learned what someone else had done only when its own wake clock next fired — up to a whole period late, and a render per period per window spent finding out that nothing had happened. Nothing in the protocol required that: a Batch is server→client, so a server that knows something changed can simply say so. Call it where the change is written, not on a timer.
  • Chart colour roles. series.1–series.5 join the theme's role table, so a chart names a categorical series instead of hard-coding a hex. A view using one was previously refused with EUI: unknown colour role 'series.1'.
  • scroll_to on a node — a server-driven render can move a scroll position, not just describe one.
  • position: "pointer" places a node where the pointer is, rather than in the flow or a stack.
  • location and nfc_tag events. The client compiles the code behind them only for a phone — a desktop has no tag reader and no positioning it can reach — the same way file dialogs are excluded on a phone.
  • The scaffolded builder catalogue grew by ~650 lines, including the chart builders that use the new series roles.

Tooling

  • soli fmt is idempotent again on a postfix guard whose value wraps. Formatting return text(glyph, { ... }) if name.nil? broke the value across lines, which stranded the trailing if where the next pass could not see it — so a second soli fmt rewrote the guard into a block if. The formatter now emits the block form on the first pass, matching the rule it already applied when creating a guard, so fmt(fmt(x)) == fmt(x). A short guard still stays postfix.
  • SOLI_TEST_SERVER_DEV=1 gives a spec run a dev-mode server. soli test starts its servers in production mode so specs exercise the same bytecode VM that serves production. A spec covering a dev-only feature — the dev bar, which serve only injects under --dev — needs the flag back; set this for that run and keep the rest of the suite on the VM.

Uploads

  • An attachment download no longer costs ~29× its own size in RAM. AttachmentsController#show ended with "body": Base64.decode(b64), and Base64.decode on bytes that are not valid UTF-8 returns a Soli array of one 16-byte integer per byte — precisely the blowup removed from the ingest side, reintroduced on egress. It now answers with "body_base64", which the response layer already understood and decodes once, straight to bytes. Measured on a 16 MiB round trip through a real server: peak RSS growth 468 MiB before, 118 MiB after. If you overrode AttachmentsController#show and copied the old shape, switch to body_base64 too.
  • An upload is no longer copied five times on the way in. The body was collected, copied into a buffer, copied again into the multipart parser, copied a third time per part, and also retained whole in a request field nothing ever read — carried across the worker queue on every upload. The body is now refcounted and moved into the parser, and parts borrow from it. file["data"] is unchanged, still the same base64 string, so nothing to migrate. With the download fix a 16 MiB round trip goes from 468 MiB to 102 MiB of peak RSS growth.
  • SOLI_MAX_INFLIGHT_BODY_BYTES bounds the sum of request bodies buffered at once. SOLI_MAX_BODY_SIZE only ever bounded one request, while bodies are buffered per connection and every parsed request then waits in the worker queue still holding its payload. A request now reserves its share before any bytes are buffered and returns it when the request ends, on every path; over the ceiling the server answers 503 with Retry-After. Defaults to 16× the per-request cap (128 MiB); 0 disables it. See Hardening.

v2.2.0 — 2026-09-11

ORM / database

  • Batch iteration: find_each and in_batches walk a whole collection holding one batch in memory instead of materialising every row — User.where({ "active": true }).find_each(fn(user) { ... }, { "batch_size": 500 }). Until now a data-correction command or a score computation over a whole scope was bounded by RAM rather than by anything the author chose, and .each was no escape hatch: it materialises first, then iterates. Paging is by key (FILTER doc._key > <last key seen>, sorted by _key), not LIMIT offset, n — offset paging degrades as the offset grows and skips or repeats rows when the collection is written to mid-scan, which is exactly what a long correction job does to the collection it is correcting. Deleting or updating inside the block is therefore safe. Both spellings work from the class and from a chain, on SoliDB and the SQL adapters, and find_in_batches is an alias.
    • Clauses keyset paging cannot serve are refused, not silently ignored: .order, .limit, .offset, .pluck, the aggregate and grouping modes, .similar, and calling one inside grouped(...). A dropped clause in a correction run is a wrong run that still reports success.
    • batch_size defaults to 1000 and is capped at 10,000.

    Auth & security

    • The locale no longer carries from one visitor to the next. The active locale lives in a thread-local, workers are reused between requests, and nothing in the request path reset it — so a controller that did not call set_locale rendered in whatever language the previous request on that worker had asked for.
      • It was a write-side bug too. Model#save and #update store pending translations into the slot named by the current locale, so one visitor's edit could be persisted under another visitor's language — not merely displayed in it.
      • A request now begins with a locale of its own: a locale value in the session, else a locale cookie, else the best match for Accept-Language among the locales you actually ship (fr-CA is served by a fr you have), else SOLI_DEFAULT_LOCALE — and it is restored when the request ends. Realtime frames (Live View, EUI) resolve it from the socket's session the same way.
      • Covered by an end-to-end test that serves two requests from one worker: without the fix the second renders in the first's language.

    Fixes

    • A read of more than 1000 rows no longer silently loses its tail. SoliDB returns a query result one cursor batch at a time (1000 rows by default), setting has_more and a cursor id when more remain; the ORM's read path took the first batch and never followed the cursor. Every Model.all, unbounded .where, grouped {} flush and SDBQL literal was capped at 1000 rows with no error — a correction script over 5000 rows processed 1000 and reported success. It presented as intermittent, because a query-cache hit returns the whole set with has_more: false: the same query gave 5000 rows warm and 1000 cold. The cursor is now drained to completion, as the db.* client already did. A single-batch read is unchanged and pays nothing.
    • Plurals follow CLDR, so French no longer renders English. The suffix was picked by a locale-independent ladder — 0 → _zero, 1 → _one, else _other — applied to every language.
      • French has no zero category: 0 and 1 are both one (« 0 article »). A French page with a correct fr.yml therefore asked for items_zero, did not find it, and fell back to the English string. The repository's own test asserted that as the expected result.
      • Russian and Polish could not reach _few or _many at all; Japanese and Chinese were given a singular/plural distinction they do not have; Arabic five of its six forms were unreachable.
      • Categories are now per language, with hand-written rules for the languages the docs ship and one/other for the rest. A _zero key is still honoured for a count of 0 in any language, and a category your file omits falls back to _other in the same locale before another locale is consulted.
    • t() translates. It returned its own argument — t("welcome.title") rendered the literal welcome.title — while the views documentation gives it as the way to translate; only I18n.translate ever worked. It now resolves the key in the current locale, interpolates a values hash, and escapes interpolated values for a key promising HTML. A missing key still renders as itself, so an untranslated string is visible rather than blank.
    • The fallback locale is yours to choose. "en" was hard-coded in six places with no way to change it, so an application whose primary language was not English fell back to one it might not even ship. SOLI_DEFAULT_LOCALE and I18n.set_default_locale(...) now set it; it remains en by default.
    • An event for a handler the tree no longer offers is ignored, not fatal. The server looks every event up in the tree the encoder last sent, and ended the session with a 300 when it was not there. Usually that is a race rather than an attack: a handler a render removed is still in the client's tree for the one round trip the new tree takes to arrive, and whatever the pointer does in that window arrives naming it.
    • A suite now runs on the engine that serves production. soli test started its servers with --dev, and a server only builds a bytecode VM when it is not in dev mode — so every spec ever written ran on the tree-walking interpreter while production ran on the VM.
      • A handler the VM refuses passed the whole suite, and SOLI_FAIL_ON_VM_DEMOTION — the guard meant to stop exactly that reaching CI — could not see it, there being no VM to demote from.
      • Nothing was gained by --dev there: hot reload watches files nobody edits in a process that lives one run, and the per-request dev instrumentation (the AQL log behind dev_queries(), the render-time headers, the dev-bar snapshots) is work no spec asked for.
      • Measured at equal build profile on a 142-spec suite: 114.9s before, ~114s after — the same, and now with zero VM demotions across the run.
    • The test databases an interrupted run leaves are swept at the start of the next one. The post-suite drop only knows the databases of the run it is ending, so a Ctrl-C, a timeout or a panic left them behind — as did the extra workers of a run made with a wider --jobs. One SoliDB instance had reached 117 abandoned databases, holding every one of its SST files open. Only {stem}_w{N} names built from the run's own base are candidates: never the base itself, never a name another application could own.
    • A killed runner takes its test servers with it. The guard that stops them covers every way a suite can finish and none of the ways it can be killed, so an escalated Ctrl-C left soli serve children adopted by init — three were found alive six hours later, still holding database connections and re-creating their _test databases the moment anything dropped them. The kernel is now asked for the guarantee instead.

    Added

    • soli new <app> --eui starts an application with a native window as well as a web page. It writes the EUI widget catalogue as app/controllers/eui_builders.sl — 150 plain-Soli functions, from buttons to date pickers to charts — a first component beside it (a counter: its handler, its view), and the router_eui line in config/routes.sl.
      • There was no other way to get the catalogue. The EUI spec calls it the reference catalogue and states a contract for it, but it shipped only inside that repository's sample application: reaching it meant copying 3 200 lines out of an example by hand.
      • Nothing there is native. Every widget is a function over the fourteen primitives the client knows, so the catalogue is yours to read, change and add to — and a new widget needs no new client.
      • Refused alongside --template, which decides for itself what an application holds, and refused by a build without the eui feature, which could not serve what it wrote.
    • I18n.set_default_locale(locale) and I18n.default_locale(), with the SOLI_DEFAULT_LOCALE environment variable. See Which locale a request runs in.

v2.1.1 — 2026-09-10

Added

  • A dev bar for EUI, from eui_stats(). An EUI window has no document to splice a bar into, so the bar is a widget the application places — an overlay pinned to the bottom — and this builtin is where it gets its numbers: the previous render's, which is what a bar reports. What it counts is what EUI costs and nothing else: the view and the encode in milliseconds, the ops and the bytes that went on the wire, the seq, the node count, and the four tables a session interns once. It answers with an empty hash outside --dev and before the first render, so a view can compose dev_bar(eui_stats()) unconditionally and ship it: what is not measured draws nothing.
    • Nothing the window knows is in it. Frame time, quads, memory — the client prints those under EUI_TRACE and reports none of them: spec 08 says a window reports nothing about the machine beyond its viewport, and a dev bar is not a reason to change that.
    • One render behind, always. A bar reports the work that produced what is on the screen, which is the only honest thing it could report.

Build

  • EUI is now part of the default feature set. v2.1.0 shipped it behind --features eui and said the default build contained none of it; that is no longer true. A stock cargo build --release or cargo install --path . --locked now has router_eui, eui_capabilities and the /_eui/session/<component> socket, so an app whose routes.sl calls router_eui no longer fails to boot with Undefined variable 'router_eui' on a binary installed the ordinary way.
    • full gains it too, so it stays a superset of the default set.
    • Still off: eui-desktop — the winit/wgpu window that soli desktop build --eui needs. Servers do not link it.
    • Drop EUI again with --no-default-features plus the features you want; see Slim binary.

Auth & security

  • An EUI component is only reachable over its own socket. router_eui registers the handler in the registry the JSON LiveView socket reads, and the worker dispatches on the component name, so /live/socket/<eui component> reached an EUI handler with an event name and a params/props hash of the client's own writing — the check that every binary-socket event is validated against the tree the client was last sent never ran. It answers 404 now.
  • connect can refuse a client. No middleware runs for a WebSocket upgrade, and there was no way to say no: whatever connect returned, the view was rendered and sent. A handler may now return {"close": reason} from connect or any later event — the client gets an Error frame (403) with the reason and the session ends — and router_eui(component, handler, view, {"session": "required"}) refuses a cookie-less upgrade with 401 before any handler runs. See Who may connect.
  • The socket has admission, a handshake timeout, a heartbeat and a frame budget. EUI (and LiveView, and live-reload) sockets are admitted against SOLI_WS_MAX_CONNECTIONS and the per-address cap like /ws/*; a client has ten seconds to say Hello; the server pings every thirty seconds and closes after two go unanswered; inbound frames are charged against the /ws/* budget, a Resync twenty at a time. A client's Error message reaches the log with control bytes stripped, and EUI_TRACE prints session state only under --dev.
  • The Welcome frame carries a session handle, not the cookie. It named the session by the first sixteen bytes of the raw session id — the value HttpOnly keeps from page scripts, and the very thing the LiveView socket stopped sending. It is a SHA-256 handle now.
  • An image src is confined to public/ and app/assets/. Any file under the app root could be read, hashed and served to anyone with the hash — the publisher key and .env included — whenever a view built a src from data.
  • A duplicate key among siblings is an error, not a worker restart. It panicked the keyed diff, which restarted the realtime worker and reloaded the application — repeatable by any client whose row keys came from its own data. Duplicate keys are refused with a reason, the diff clamps its indices, and LiveView events now run behind the same catch_unwind HTTP handlers have.
  • The server enforces the client's limits before sending. Tree depth, node count, inline text length and the atom/style/colour/chunk table ceilings are refused with a reason the view author can read; the client used to refuse the batch silently, and a deep tree could run a worker off its native stack.

Performance

  • Each EUI session is pinned to one realtime worker, and the realtime side is a quarter of the pool. It was one thread at every size, so every EUI frame in the process queued behind the one before it. Growing it exposed what the memo assumed: it keeps a rendered card by the identity of the view object it came from — an interpreter value, so it lives on one thread — and a session landing on whichever worker was free found it cold three times in four. Pinned by session id, the memo hits every render; keyed rows inside a kept card stay warm; and the memo is freed when the session ends — it was cleared on the wrong thread, and every closed session's last tree stayed resident for the life of the process. A closed EUI session is now detached and reaped after DETACHED_GRACE like a LiveView one.
  • A slow reader closes; it does not silently lose batches. A render of more than 32 frames — a list of thirty thousand rows — or a client a little behind dropped whatever did not fit its queue, and the client kept a tree the server had moved on from. The worker waits up to two seconds for room, then closes the socket and the client resyncs. Error frames now say when the session is really over: 503 (queue full), 504 (no answer in thirty seconds), 500 (worker gone).
  • A regression net for the encoder, and what it caught. benches/eui_render.rs renders a 10k-row keyed list six ways. Against it: a cold render reads the view's values directly instead of a per-node JSON map; styles are looked up by fingerprint; local(…) handlers compile once per session; atom names are O(1); an event resolves its handler and props in one tree lookup, off the reactor thread; batches are bounded by weight, so a tree of any size the client accepts can be mounted (a first render was one op however big); the keyed reorder runs in C log C with removal runs coalesced; and the handler's returned state goes to the view as the value it already is. Reversed list 56 → 27 ms, cleared 6.2 → 4.1 ms, values rebuilt 87 → 62 ms.
  • Assets by handle. An asset GET is a refcount, not a copy of up to 16 MiB; the store evicts the least recently used instead of refusing forever at 256 MiB; an image node no longer costs three syscalls per render; the manifest is signed once per capability mask; the publisher key is created private and the scaffold's .gitignore names it.

Fixes

  • An EUI local handler was thrown away whenever its style id happened to be 32 or 64. The widget kept working through the server — a hover that waits for the round trip, a toggle that flickers — but the local chunk never ran, and the only sign was one line on the window's stderr: malformed chunk: code does not end in return or jump.
    • Why it moved around. A chunk must end in return or a jump, and the client refuses the whole handler otherwise. The compiler appended that return unless the code already ended in one — and it decided by looking at the last byte. A set_style ends in its style-table id as a varint, so the ids 32 and 64 are the bytes 0x20 and 0x40: the operand read as the jump and return opcodes, the terminator was skipped, and the chunk ended in the middle of an instruction. Which handler broke therefore depended on how many styles the view had interned before it, so adding an unrelated widget could break — or fix — a handler somewhere else on the page.
    • The terminator is now decided from the last instruction the compiler emitted, which is the only thing that can answer the question. Covered by a test that compiles band.style = @lit for both dangerous ids and asserts the return is there.

v2.1.0 — 2026-09-08

Docs

  • The EUI widget catalogue is documented. A reference for all 79 user-facing builders, grouped into twelve sections — layout, typography, actions, input, dates, feedback, overlays, navigation, data, charts, media and theme — each with its signature, what it is for, and a Soli sample. 67 carry a rendered preview beside the sample, drawn in HTML and SVG and labelled as approximations: the real widget is painted on the GPU by a native client and cannot render in a browser. Signatures were read from eui_builders.sl rather than from prose, so nothing unimplemented is listed. The pages also say what the docs left implicit — the catalogue is not a shipped library, but one file in the counter-app example, meant to be copied into an application and edited. See EUI — the widget catalogue.

Added

  • EUI sessions, behind a cargo feature. cargo build --features eui adds router_eui(component, handler, view) and the /_eui/session/<component> socket. An EUI component is a Live View component whose view is a function of state returning a node tree as plain data; the server interns atoms and styles once per session, diffs against the tree it last sent, and streams binary patches to a native client that draws them on the GPU — no HTML, no CSS, no JavaScript, no browser. See EUI.
    • Off by default. The default build contains none of it — the session path string is absent from the binary.
    • Local-first handlers. {"local": [...], "then": "increment"} is assembled by the server into a verified, fuel-metered bytecode chunk the client runs before the round trip.
    • Events carry the node's props, so a row in a list can say which row it is (params["props"]["id"]). A pointer payload is local to the node holding the handler, not the leaf under the pointer.
    • A native window. soli desktop build --eui <component> (feature eui-desktop) packages an app that opens its EUI component in its own GPU window — no browser. The server runs on a thread behind the loopback gate; the embedded client presents the session as a cookie.
    • Moving pictures. A video node plays a GIF or an animated WebP, named like an image, with playing, loop and position props and an ended event. Decoded in the sandboxed worker; the window wakes exactly when the next frame is due.
    • Sound. An audio node names a sound like an image names a picture, with playing, volume, loop and position props and ended / time_update events. Only the window opens a device.
    • The viewport reaches the application. params["viewport"] with connect and a viewport event on change: responsive views. A desktop window closes cleanly on Ctrl+C.
    • Windowed lists. A list with count, heights and a window handler holds only the rows in view; the client asks for a range when it changes. Forty thousand posts cost the server one window of cards.
    • Two server workers. A desktop artifact no longer starts one worker per core: one person at one window needs two, and each spared worker is an interpreter's worth of memory (117 MB at rest with eight, 68 MB with two). --workers and SOLI_WORKERS still override.
    • --no-db and --db-url. A desktop artifact no longer has to carry and start a database: none at all, or one elsewhere, with credentials from the app's .env. The manifest records the choice; older artifacts read as embedded.
    • A local handler can switch the palette. theme.toggle() and theme.mode = "dark" in a local handler: the viewer's choice, no round trip, never provisional.
    • Renders without copying. The view's value becomes nodes directly, the previous tree is diffed in place, and a keyed child whose view value is the same object as last render is kept — not converted, not diffed; the keyed diff is linear. A like on a feed of five thousand cards costs one card. A keyed node hash returned unchanged is assumed unchanged.
    • A signed manifest. GET /.well-known/eui is signed with an Ed25519 key generated on first use into config/eui_publisher.pkcs8 — keep it, clients pin it, never commit it. eui_capabilities(…) in routes.sl says what the app asks for.
    • Canvas paths. A canvas node's paths prop is a list of [kind, colour, numbers…]; the server resolves the colour before encoding, and the catalogue's four charts build on it.
    • Touched: src/serve/mod.rs (three #[cfg(feature = "eui")] insertions), one builtin in router.rs, and the new src/serve/eui/ module. live/, template/, vm/ and interpreter/ are unchanged.

v2.0.7 — 2026-09-06

Fixes

  • .where intermittently rejected your own filter literals, raising the client-supplied-operator error on a filter no client can reach — roughly 3 requests in 60 on a deployed app, concentrated on pages whose params are empty.
    • Why it looked random. The guard that stops {"ne": null} from erasing an equality check remembers which containers arrived with the request. It remembered them by address only, and an address is an identity for no longer than the allocation lives. A request with no params gets a freshly allocated empty hash, marked and installed as params; dispatch replaces that global, the hash is freed, and its address stays behind. A later Model.where({"status": {"gte": 400}}) allocates a small hash, lands on the recycled block, and inherits a mark it never earned — so the guard fired on the code it protects.
    • The table now keeps the value alongside the address, which pins the allocation for as long as the mark can be consulted. The cost is one request's parsed body held per worker thread between two requests, bounded by the body-size limit and the worker count. Nothing changes in application code, and the injection guard itself is unaffected: a container that really did arrive with the request is still refused.

v2.0.6 — 2026-09-04

ORM / database

  • Fixed: a Model.where(...) chain inside a try/catch failed on the server, with Cannot access property 'limit' on QueryBuilder (or 'first', 'order', …). The VM — the engine soli serve uses outside --dev — had no arm for query builders at all; where, limit, first, the aggregates and scope chaining live only in the interpreter.
    • Why it bit in one place rather than everywhere. The old error was catchable by application code. Normally it escapes, serve demotes the handler to the interpreter, and the chain works — which is why the same code ran fine in most routes. A handler with its own try/catch around the model call swallowed it and reported its own failure, so serve never learned it had to demote and that route stayed broken for every request.
    • The VM now hands query-builder access back through the same EngineFallback route class reflection and dynamic finders use. That refusal is deliberately not catchable by user code, so it always reaches serve; the handler demotes once and is then blacklisted, costing one re-run rather than one per request. Workarounds of the shape .limit(1).all written to dodge this can go back to .first.
  • .first(n) on a query builder returns the first n records as an array — Post.where({"published": true}).order("created_at", "desc").first(3). It is .limit(n).all, spelled the way Rails spells it; the no-argument .first still returns a single record or null. Refused on aggregate and exists queries, which return one value rather than rows, and on a negative count. See Query Builder.

Dev tools

  • A project can pin the exact soli it runs on. soli.toml's existing soli_version field gains an exact form — soli_version = "=2.0.3" — and soli run inside that project switches to soli 2.0.3, fetching and verifying it the first time and caching it under ~/.cache/soli/runtimes/. The plain soli_version = "2.0.3" form is unchanged and still means "at least". Same idea as .nvmrc or a rustup toolchain file, resolved the same way: by walking up from the current directory. It works under soli-proxy with no proxy change, because the proxy already starts an app with the app directory as its working directory.
    • soli which reports which version will run here, from which manifest, and whether it has been downloaded yet. SOLI_NO_PIN=1 skips the switch, for CI, air-gapped machines and bisecting a version-dependent bug.
    • Commands that must act on the soli you invoked ignore the pin: soli update, soli new, soli which, --version and --help. soli update especially — redirected, it would overwrite a cached toolchain with a different version.
    • A pin is exact in both directions, and a pre-release does not satisfy the release it precedes. Pins are validated before becoming a path or a URL, because a soli.toml is author-controlled content in any repository you clone.
    • A pinned fetch refuses a release that publishes no checksum, where soli build --target only warns: those bytes are about to be executed rather than embedded. It also names the version and the manifest before downloading, since a cloned repository now chooses the interpreter that runs its code.
    • Older soli binaries ignore a pin rather than failing on it — they read "=2.0.3" as an unrecognised minimum and conclude it is satisfied. Adding a pin does not break collaborators who have not upgraded.
    See Modules & Packages.

v2.0.5 — 2026-09-03

Fixes

  • has_many on an unsaved owner no longer hits the database. Its builder can never match a row, so update_all, delete_all, count and exists? answer without a round-trip instead of sending a query that, with no database reachable, came back as an error.

v2.0.4 — 2026-09-03

Fixes

  • Sessions on the disk, solidb and solikv drivers survived exactly one request. Since 2.0.3 the request resolver asks the store whether a cookie's session exists rather than minting one for an unknown cookie. The default exists probed a key that no session ever holds, so on these three drivers every login was forgotten by the very next request (the login page kept coming back). Each persistent store now answers from the stored session and its expiry.

v2.0.3 — 2026-09-03

Security audit

A full-repository audit produced 57 findings, all fixed in this cycle. Most need nothing from you. The ones below change behaviour you may be relying on — read these before upgrading.

  • --dev refuses to start when APP_ENV is production. It used to skip every production boot check silently — no SOLI_APP_HOSTS, no session-secret floor, security headers off, the /__solidev diagnostics exposed — with nothing in the output to say so. Drop the flag, or set APP_ENV to something else.
  • enable_trust_proxy is off by default in new apps. An X-Forwarded-* header is only trustworthy behind a proxy that rewrites it; on a directly-exposed app it let any client spoof the request authority and scheme, and take a fresh identity per request so the login throttle never tripped. Uncomment it when you deploy behind a proxy, and name the hops with the new SOLI_TRUSTED_PROXIES (IPs or CIDRs, comma-separated) so the headers are honoured only for requests that really came from them.
  • cors() refuses credentials: true with a wildcard origin. Browsers reject Allow-Origin: * alongside credentials — that refusal is the safety net — and Soli sidestepped it by echoing back whatever Origin asked, so every cookie-authenticated route on the path was readable and writable by any website. List the origins explicitly. A wildcard rule also no longer waives the CSRF origin check; only a listed origin does.
  • .where({...}) refuses a request-supplied hash or array as a value. The hash form reads a nested hash as operators ({"gt": 10}) and an array as IN — the same shape a JSON body can send. A client posting {"token": {"ne": null}} turned an equality check on a secret into != null and walked past it. Values that arrived with the request are now tracked, and one that would act as an operator raises with the field named. Operator hashes you write in source are unaffected.
  • Model.create / Model.update never persist _key, _id, _rev, _from, _to or an STI type from their input, with or without attr_accessible. And the static Model.update(id, hash) now runs validations, against the merged record rather than the patch, returning {"_errors": [...]} on failure — it used to write straight past every rule the model declared.
  • LiveView rooms are opt-in per component. Declare them with live_rooms("desk") in config/routes.sl. ?room= on an undeclared component handed its full rendered HTML, and the right to drive its events, to anyone who guessed the name. A client may also only send _assigns keys the server rendered as soli-assign-*, and only address components present in its own markup.
  • The email-confirmation link no longer signs the visitor in, and expires after 48 hours; a password reset now invalidates existing sessions. Both are in the generated auth scaffold. A confirmation link is a GET from an email, so a link scanner or a forwarded message received the session.
  • /_metrics is no longer public. With SOLI_METRICS_TOKEN set it requires that bearer token; without it, only loopback and private-range peers.

The rest, in brief: request bodies, WebSocket frames, connections, page sizes, parameter counts and handler run time are all bounded now, so one client can no longer take a worker (or the process) down; the production error log stops printing cookies and passwords; Web Push, webhooks and PDF images go through the same request-forgery gate as HTTP.*; and the escaping story gained json_script() for JSON inside a <script>. See the repository CHANGELOG.md for the full list.

  • New knobs. SOLI_TRUSTED_PROXIES, SOLI_METRICS_TOKEN, SOLI_HANDLER_TIMEOUT_SECS, SOLI_WORKER_STACK_MB, SOLI_MAX_CONNECTIONS, SOLI_WS_MAX_CONNECTIONS, SOLI_WS_MAX_CONNECTIONS_PER_IP, SOLI_WS_MAX_MESSAGES_PER_SEC, SOLI_BODY_READ_TIMEOUT_SECS, SOLI_MAX_PAGE_SIZE, SOLI_MAX_PARAM_PAIRS, SOLI_MAX_RANGE_LEN, SOLI_MAX_STRING_ALLOC_BYTES, SOLI_RATE_LIMIT_IPV6_PREFIX. Every one has a working default; set them only if a limit gets in your way.

HTTP client

  • headers in the options hash of every HTTP.* verb. HTTP.get(url, {"headers": {"Authorization": "Bearer " + token}}) now sends the header — as do post, put, patch, delete, head, the *_json / get_jsonp variants, and get_all / get_all_json for every URL in the batch. That is the shape the docs already showed, but the runtime read only timeout from the hash and dropped the headers on the floor, so an authenticated call went out anonymous unless you fell back to HTTP.request.
    • Yours replaces the default. A Content-Type on the body verbs or an Accept on the JSON variants overrides the builtin's own, matched case-insensitively, so a request never carries two.
    • String values go out verbatim, numbers and booleans are stringified, a null value skips that header. A name or value the client would refuse (a space in the name, a CR/LF in the value) raises before sending, naming the header (never its value, which is usually the credential), instead of surfacing at send time as an opaque builder error. Content-Length and Transfer-Encoding are refused on every path, HTTP.request's flat hash included: the client sends a caller-supplied length verbatim, and a mismatch desyncs the upstream connection.
    • HTTP.request(method, url, headers) keeps its flat headers hash and also merges a nested headers key, so the one options shape works everywhere.
    See HTTP.get.
  • The static checker knows the whole HTTP class. A top-level script, soli check and soli -e type-check before running, and the typed HTTP declaration was a partial list with no options parameter — so HTTP.get(url, {"timeout": 5}) failed with Wrong number of arguments: expected 1, got 2 and HTTP.get_json(…) with Cannot access member 'get_json' on type 'HTTP' before a byte was sent. Every runtime verb is now declared with its trailing options hash, and indexing a Future (HTTP.get(url)["status"]) is accepted the way member access on one already was, instead of cannot index Future<String>.

v2.0.2 — 2026-09-02

ORM / database

  • db.timeout(secs) and db.query(sdbql, binds, {timeout}) raise the 10s ceiling on raw SDBQL. The QueryBuilder form already gave one Model read more than ten seconds; a Solidb client had no equivalent, so a heavy db.query died at ~10s — and the two obvious spellings failed before they even reached the database (Cannot access property 'timeout' on the chain, query() expects 1 or 2 arguments for an options hash).
    • db.timeout(60).query(sdbql) sets the budget on the client (chainable; persists until you change it). db.query(sdbql, binds, {"timeout": 60}) overrides one call. Seconds as Int or Float; a zero, negative, non-numeric value or an unknown option key raises.
    • A read db.query inside grouped joins the batch when the client targets the same host and database as the ORM, so the largest .timeout any member asked for covers it. A write via db.query still runs immediately.
    See Query timeouts.

v2.0.1 — 2026-09-02

ORM / database

  • .timeout(secs) raises the 10s ceiling on one slow query. Every read reaches SoliDB over HTTP, and that client allows a request 10 seconds — a deliberately tight backstop against a stalled connection pinning a worker, but hardcoded, with nothing to turn. A report over a large collection or a multi-aggregate group_by died with Error: HTTP error: error sending request for url … however healthy the database was. Order.where(…).timeout(120).all() now gives that one query two minutes, as does the Model.timeout(secs) static entry point.
    • Chainable and position-independent like .limit. Seconds as Int or Float, so .timeout(0.5) is a valid half-second budget — and a zero, negative or non-numeric value raises instead of being ignored, so a typo cannot silently leave the 10s default in place.
    • Scoped to the one request and reverted the moment it finishes, error included, so a raised timeout never leaks into the next query on that thread. It lowers as well as raises: .timeout(2) fails fast rather than making a user wait.
    • A grouped block runs under its most patient member. Coalesced reads are a single request, so the batch takes the largest .timeout any query in it asked for — taking the minimum would have timed out the very query that asked for more room.
    • No effect on the SQL adapters. Postgres, MySQL and SQLite talk over their own connection pool rather than HTTP, so the 10s cap this lifts does not exist there and there is no statement timeout to set in its place. The call is accepted so a query stays portable across adapters; bound a SQL query with the server's own statement_timeout / max_execution_time.
    See Query timeouts.

v2.0.0 — 2026-08-31

Breaking changes — background jobs

Read this before upgrading. Jobs now run inside the Soli process against a _jobs collection on your default connection, instead of being driven by SolidB calling back into your app. Every public API is unchanged — job classes, Job.*, Webhook.*, Cron.*, perform_later and static cron keep working as written — so this is an operations and behaviour change, not a code rewrite. The first item below is time-sensitive.

  • Drain the SolidB job queue before you upgrade. Jobs still sitting in SolidB's internal queue are invisible to the new engine — nothing migrates them, and they will never run. Let the queue empty on the old binary first, then deploy.
  • The job callback endpoint is gone. POST /_jobs/run/:name no longer exists, and SOLI_JOBS_CALLBACK_URL, SOLI_JOBS_SECRET and SOLI_JOBS_DATABASE are ignored — drop them from your deploy config. Because the database no longer calls back into the app, any firewall rule or ingress that existed only to let it reach you can go too.
  • SOLI_WEBHOOK_SECRET changed meaning. It now signs outgoing Webhook.* deliveries only. It no longer authenticates anything inbound, so do not keep treating it as a shared secret a caller can present.
  • Failed jobs are retried instead of dropped. Jobs no longer ack before running, so a handler that raises is retried with backoff (5s, doubling, capped at 1h) rather than vanishing. The old fire-and-forget caveat is gone — which means a non-idempotent handler can now run more than once. Check any job that charges, emails, or posts externally.
  • A malformed cron expression now raises where you declare it. Expressions are six-field; a five-field string used to be accepted and then silently never fire. It is rejected at declaration now, with a message naming the shape — so an app carrying a bad expression fails loudly on boot instead of quietly doing nothing. Fix the expression, don't remove the check.
  • static background: Bool = true is accepted but does nothing. It opted a job out of running on a web worker; that is now the default for every job, so the flag has nothing left to ask for. Existing declarations keep parsing.

SQL connection security

  • Postgres and MySQL connections speak TLS. Neither client had a TLS implementation compiled in at all — the Postgres pool was built with NoTls and the mysql crate resolved with no TLS backend — so ?sslmode=require was accepted and then ignored, and every managed database (RDS, Cloud SQL, Neon, PlanetScale, Aiven) needed a proxy or a private network in front of it. Both now use rustls with the ring provider, the same provider the mail and HTTP clients use, so no system OpenSSL is involved and a cross-compiled binary keeps it.
    • libpq's ladder, and libpq's semantics. disable / prefer / require / verify-ca / verify-full, with MySQL's own spellings (DISABLED … VERIFY_IDENTITY) parsing to the same rungs. Encryption and identity stay separate: require encrypts but does not check who answered, and verification starts at verify-ca — so a URL behaves exactly as it does under psql, and a self-signed server keeps working.
    • prefer is the default, so a server that offers TLS gets an encrypted connection with no configuration at all and one that does not still connects. sslmode=disable asks for the old cleartext behaviour explicitly.
    • A CA file replaces the built-in roots (sslrootcert= on Postgres, ssl-ca= on MySQL), and one supplied with a mode that would never consult it is refused rather than silently ignored. Both options are lifted off the URL before the driver's own parser sees it, since tokio_postgres rejects sslrootcert outright and mysql rejects any parameter it does not know.
    • A mandatory mode cannot be satisfied by a Unix socket. The MySQL driver skips TLS on a socket connection outright, and it prefers a socket for a localhost URL — which would have satisfied REQUIRED with a cleartext connection. REQUIRED and up now take TCP. Postgres never negotiates TLS on a socket either, so require fails there instead of pretending.
    • A TLS failure says what happened. The pool used to retry a failing connection until its timeout and report timed out waiting for connection, burying the reason; a mandatory mode is now probed once when the pool opens, so the error reads connection "primary" asked for sslmode=require: error performing TLS handshake: server does not support TLS immediately.
    See Postgres → TLS and MySQL → TLS.
  • The SQL adapters are a CI gate. Their tests skip when no server answers, and a skipped test still reports ok — cargo test swallows the message for a passing test — so a green build never actually meant Postgres and MySQL had been exercised, only that the SQL compilers and SQLite had. CI now runs both as service containers, with TLS switched on in the Postgres one so the encrypted session is asserted rather than assumed, and SOLI_REQUIRE_DB=1 turns every would-be skip into a failure. Locally, without the flag, the suite skips exactly as before.

PDF

  • SVG images are embedded as vectors. A template image whose source is SVG is converted with svg2pdf and placed as a Form XObject instead of being rasterised through resvg/tiny-skia. Logos stay sharp at any placed size; <text> still uses font_dirs.
  • An embedded SVG produced an unreadable PDF. The imported Form's own resources — the ICCBased colour space svg2pdf always attaches, plus a FontFile2 for <text> and any raster the SVG references — were written inline in the resource dictionary. PDF only permits a stream as an indirect object, so every SVG-bearing document came out corrupt: qpdf reported expected endobj, poppler could not parse the object, and the artwork did not render. Nested resource streams now get their own objects and are referenced.

Docs

  • Rust internals. Crate map, pipeline, interpreter, VM, serve, SQL adapters, and a type/method catalog aimed at junior Rust contributors. Served as Markdown views (no parallel .html.slv).
  • /ai landing page. A public “Soli is built for AI” page in the same role as Rails’s /ai: convention, token efficiency, the agent contract, and LLM/RAG in the binary.
  • Agents on Soli. Stage 1 comparison on atomic Soli tasks (evals/, 12 slugs, graded with soli lint + soli test). First frozen runners: claude -p, OpenCode + DeepSeek, Grok Build. The /ai table stays empty until a paid run is committed to www/data/ai_evals.json — no invented scores.
  • What shipped in v2.0.0. A tour of the cycle — SQL as a real backend, in-process jobs, LiveView rooms and hardening, unless … end, auth that no longer helps attackers. The changelog stays the ledger; this post is the map.
  • Taking Payments with Stripe. A Checkout Session + signed webhook walkthrough — no soli generate stripe. Verify Stripe-Signature on req["body"], fulfill once, treat the success URL as display only.

ORM / database

  • Migrations auto-load models. soli db:migrate loads app/models and app/services before each migration (recursive, same order as soli serve and db:seed). Data migrations can call User.create(...) or iterate User.all() without an import. Engine migrations load models from the engine root the same way.

Language

  • Sub-expression comprehensions and binding match compile on the VM. Nested [x for x in xs] and out.push(match i { n => … }) used to demote the whole handler to the tree-walker. They now wrap in a zero-arg lambda so the result sits at a real local slot.
  • grouped(fn() { … }) and Model.transaction { … } run on the VM. The compiler no longer refuses the block forms; the native-call path uses the same begin/flush and begin/commit helpers as the tree-walker. Because a transaction can now commit on the VM, a handler that fails after committing is no longer re-run on the tree-walker — the retry would repeat the committed writes.
  • Reading a grouped deferred result works everywhere on the VM. Property access already resolved it; iterating one (for post in @posts), indexing it (@posts[0]) and reading it back out of an instance field now do too, matching the tree-walker.
  • SOLI_FAIL_ON_VM_DEMOTION=1 stops the server when the VM refuses a handler, so CI cannot ship a new refuse silently. It fires only on an engine-fallback refusal — not on the handler’s own errors, such as a throw or a 404 RecordNotFound — and exits the process rather than panicking, which the per-request catch_unwind would have turned into a 500. SOLI_ENGINE_LOG=1 still logs every demotion, one line per unique handler. The bytecode VM only runs outside --dev, so neither applies to soli serve --dev or soli test.
  • Backticks compile on the VM. `printf hello` lowers to System.shell, and reading a field on a Future (or a grouped deferred query) auto-resolves the way the tree-walker always did.
  • Model instance methods run on the VM. record.save() no longer demotes the handler, and a method-name lifecycle callback is compiled as a method with the record bound as this. A closure-form callback (before_save do … end) still falls back to the interpreter — it needs the scope it captured, which the bytecode path cannot reconstruct. That decision is made before the write, so a handler never demotes with the row already written.
  • Model.create / Model.update run on the VM. Before/after callbacks use the same temp-instance wrap as the tree-walker; a false veto returns an instance with _errors.
  • Class method_missing and state-machine members run on the VM. UserMailer.welcome(user) no longer demotes the handler, and order.pay / paid? / can_pay? dispatch on the bytecode path for a machine with no guard and no transition hooks. A machine declaring any of those falls back — same captured-scope reason, and checked before the state is written.
  • record.delete() with dependent: or attachments stays on the VM. Child cascades and detach_all_uploads run from the bytecode wrap; Model.delete(id) loads the row and uses the same path.
  • A handler is no longer re-run on the tree-walker once it has written to the database. The no-retry tripwire covered Model.transaction { … } commits only; it now covers any write made outside a transaction too, including the bulk SQL paths that commit on their own. A bare Post.create(params) followed later in the same handler by something the VM refuses used to insert the record twice.
  • after_create-only callbacks now fire. A model declaring an after_* callback with no matching before_* one silently skipped it in the tree-walker — so it ran in production but not under --dev or soli test. Both engines now run it, matching the documented callback table.
  • Class method_missing no longer shadows reflection and dynamic finders. On a class defining a static method_missing (the Mailer shape), Foo.send("bar"), Foo.methods() and User.find_by_email("x") dispatched into method_missing on the VM and returned a wrong value. It is now the last resort on both engines, as the tree-walker always ordered it.
  • Columnar models still refuse the document API on the VM. create / update / delete on a columnar model raise through a shared choke point, but the new callback- and cascade-wrapping paths called the native directly and skipped it — silently running the document API instead.
  • A grouped deferred result materialises when read into a container. The property fast paths pushed the placeholder straight onto the stack, so render("posts/index", { "posts": @posts }) handed a deferred to the template even though iterating or indexing one resolved correctly.
  • Model.transaction(some_fn) opens a transaction on both engines. The VM intercepts any callable, while the tree-walker recognised only a literal lambda or a do … end block — so that call committed a real transaction in production and ran untransacted under --dev. A bare identifier is now recognised too.
  • define_method and alias_method now work under --vm. Class reflection lived only in the tree-walker, so the VM answered Cannot access property 'define_method' — and its own version only accepted an interpreted function, which a compiled body never is. Both are implemented for the VM directly: a compiled body lands in the class's bytecode method table, an interpreted one in the AST table, and instance dispatch consults both. The type checker no longer rejects them either (define_method, alias_method, class_eval, send, methods, respond_to?), so they no longer need --no-type-check. The rest of the reflection surface still routes to the interpreter.
  • include of a module declared inside a function failed under --vm. The compiler resolved the module name as a global, so a module declared in a function scope — a local, or an upvalue — came back as Undefined variable, while the tree-walker resolved it through its scope chain. include and extend now use the same resolution as any other name read.
  • A method's implicit return produced null under --vm. Free functions returned their trailing expression; methods compiled it, popped it, and fell off the end. So every method written in the documented implicit-return style — def label() "x" end — returned null in compiled mode while the tree-walker returned the value, with no error either way. Methods now return their last expression, constructors excepted (they return the instance).
  • An index assignment evaluated to the container under --vm. h[k] = v as a function's last statement returned the whole hash (and h["k"] = v returned null) instead of v. All four hash-set opcodes now yield the assigned value, matching the tree-walker.
  • Nested-index fusions dropped a stack slot. The AddNestedIndex / SetNestedIndex peepholes treated the trailing Pop as optional, but they push nothing where the sequence they replace leaves a value — so a function ending in total = total + h[ks[k]] or h[ks[k]] = v returned a stray local. All four now require the Pop, as the older IncrLocal peephole already did.
  • include on a non-Model class failed the type checker. Nothing in the checker read a class's includes, so the class type carried only its own methods and soli check rejected u.greet() for class User { include Greetable }. Model subclasses escaped only because their members resolve as Any. Module members (including a module's own transitive includes, and class_methods do blocks) now fold into the class type, in a pass of their own so declaration order does not matter.
  • A module's own method lost to the module it includes. Transitive includes were applied before the module's own methods and both used first-wins insertion, so module Named { include Base; def label() {"named"} } resolved label to Base's. Own methods are copied first now.
  • A nested concern's included do never ran against the class. Hooks fired only for the modules named in the include, so with module Auditable { include Timestamps } and class Post { include Auditable }, Timestamps's hook was registered against Auditable and silently never applied to Post. Hooks now fire for every module that joins the class, innermost first — concern-of-concern being the composition the feature exists for.
  • Concern hook bodies could not see application code under --vm. They ran in a throwaway interpreter that held builtins only, so a hook calling an application helper worked in --dev and failed with Undefined variable in compiled mode. The interpreter is now seeded from the running program's globals, and a bytecode function reached from a hook body dispatches back through the VM.
  • Module hook metadata was lost to caching and to re-execution. The side table carrying included do / class_methods do was thread-local while the compiled-module cache is process-global, so a thread served by a cache hit never saw it; and it was read destructively, so a second execution of the same module declaration got nothing. It is process-global and read non-destructively now.
  • Mixin modules and concern hooks. module Name … end is a mixin (and a namespace). include / extend mix methods in. included do / extended do replay class-body DSL on the host (so a concern can validates / has_many). class_methods do installs class methods on the includer; def self.included(base) is called with the host class. See Modules.
  • Block unless … end. unless is a first-class statement, not a rewritten if !cond. Multi-line membership guards parse and stay unless through soli fmt (a short body still becomes postfix expr unless cond). else is allowed; elsif is not. Postfix expr unless cond is unchanged.

REPL

  • Ctrl+C twice exits. In the TUI REPL, Ctrl+C cleared the current line and nothing more — the reflex that leaves every other shell left Soli running, and exit / Ctrl+D / Esc were the only ways out. The first press still cancels the line, now printing ^C (press Ctrl+C again to exit); a second press with no other key in between saves history and quits. Any other key disarms it.

Auth & security

  • Production soli serve fails closed without SOLI_APP_HOSTS and a 32+ character SOLI_SESSION_SECRET. When APP_ENV is production or prod, boot refuses to start if the public-hostname list is missing/empty or the session secret is missing/short; the error names the variable. --dev and non-production env still boot without them. See Production security defaults.
  • soli new requires per-form CSRF tokens. The generated .env sets SOLI_CSRF_TOKENS=require, so a browser form post without a token is 403. Existing apps keep the Origin-only posture until they set the same variable. JSON APIs are not token-gated.
  • security/unfiltered-mass-assignment lint. Model.create(params) / .update / .create_many in controllers or services with the raw request hash is a warning; permit / _permit_params is clean.
  • File-mode HTTP responses no longer unwrap a poisoned builder. soli serve on a plain directory built redirects and bodies with .body(..).unwrap(), so a path that put CR/LF in Location panics the worker. Those sites use finish_response and return 500. See Production security defaults for what production already does versus what you still set.
  • The jobs dashboard was exempt from CSRF. The whole reserved /__soli/ namespace skipped both barriers, and POST /__soli/jobs/<id>/retry (and /cancel) is production-reachable behind Basic auth — which a browser attaches automatically, so any other site could forge it against a logged-in operator. /__soli/jobs keeps the Origin/Referer gate now, which its own same-origin forms pass. It stays out of the mandatory-token layer that SOLI_CSRF_TOKENS=require turns on: the dashboard authenticates with Basic auth rather than a cookie session, so there is no session token for the framework to embed in its own retry/cancel buttons — requiring one would 403 them.
  • SOLI_FORCE_SECURE_COOKIES was bypassed by the two-argument set_cookie. That form hardcoded its attribute string and skipped the builder that applies the flag, so set_cookie("remember_me", token) shipped a credential with no Secure even on an app the operator had declared TLS-only.
  • OpenTelemetry span names carried credentials off-box. An outbound HTTP span was named from the raw URL while the query panel beside it was scrubbed, so ?api_key=sk_live_… was exported to the collector. Span names go through the same scrubber now — which itself had two bugs: its userinfo check compared an offset against the same offset computed a second way and required < where they are always equal, so user:password@ was never stripped at all; and it returned early on success, so a URL carrying both userinfo and an ?api_key= kept the key.
  • create_many stored encrypts fields as plaintext on SQL. The bulk-insert branch bypassed the write layer that applies the transform, so a model declaring encrypts("ssn") persisted raw values through Model.create_many([…]) — and reads still looked correct, because the loader leaves non-ciphertext untouched. Nothing signalled the exposure. The bulk path now encrypts each row.
  • Attachments accepted and re-served text/html by default. has_one_attached / has_many_attached treated an absent content_types as "anything", and the blob route echoed the client-declared type with no X-Content-Type-Options — so a bare has_one_attached("avatar") was stored XSS on the application's own origin. They now default to a curated allowlist that excludes every script-executing type (no text/html, no image/svg+xml, no XML), and the blob route sends nosniff plus Content-Disposition: attachment for anything that is not an inline-safe image. Because the disposition headers are the primary defence, the list stays as wide as it safely can — images including BMP and TIFF, PDF, plain text, Markdown, CSV, JSON, Zip (both MIME spellings), the Office formats, common audio/video. Upgrading: this is still a behaviour change from "anything", so an app storing a type outside the list must name it in content_types.
  • A locked account could never lock again. In the generated auth stack, register_failed_attempt returned early whenever locked_at was set, and only locked?() cleared an expired stamp — which ran after a successful login. So once the first lockout landed, the attempt counter froze permanently: when the window elapsed, an attacker resumed guessing at full rate forever. The guard now asks locked?(), which re-arms the lockout while still refusing to slide the window forward under a sustained attack.
  • Generated OAuth clients could not authenticate, and their PKCE protected nothing. The services passed a "headers" option to HTTP.get/HTTP.post, which read that hash for timeout only and returned the body as a String — so the Authorization header was dropped and response["body"] was a type error. They now use HTTP.request(method, url, headers, body), which sends headers and returns a response hash, and check the status so a 401 reports as a 401 instead of a JSON parse failure. Separately, the PKCE helper sent the raw verifier as the challenge with code_challenge_method=plain — identical strings, so it added no protection. It now sends a real S256 challenge, Base64.urlsafe_encode(Hex.decode(Crypto.sha256(verifier))).
  • live_component interpolated its child id into HTML unescaped. The id comes from application data — the keyed-child pattern is live_component("row", {"id": row.slug}) — and reached the DOM through morph → innerHTML, so an id containing a quote could add a live event handler. Ids are attribute-escaped now, and one containing a control character (which would split the line the patch engine splices on) is refused at the call.
  • Chunked LiveView uploads are capped. The completed-file store has had per-session and global limits since it shipped, but the map holding uploads still being assembled had only an expiry sweep — and POST /live/upload is reachable without a session, because a first-time visitor may not have one yet. One unauthenticated client could therefore mint a fresh X-Soli-Upload-Id per request, send one chunk of a declared 512, and park up to 8 MiB per id until the server ran out of memory. A session now gets 4 in-progress uploads, the server 32 and 64 MiB of chunk data, and a partial upload is swept 2 minutes after its last accepted chunk — an idle deadline, so a slow connection is never cut off. A retried chunk is charged once rather than twice.
  • CSRF exemptions no longer cover your routes. Both barriers — the Origin/Referer gate and per-form token verification — skipped any path starting with /_. That was meant for the framework's own endpoints, but it is a namespace applications use: a POST /_internal/wipe or /_admin/users silently lost both checks. The exemption is now the endpoints the framework actually serves — /_health, /_ready, /_metrics, /__coverage__, and the reserved /__soli/, /__solidev/, /__dev/, /__livereload prefixes. An application route that wants out still says so with skip_csrf.
  • SOLI_FORCE_SECURE_COOKIES covers the whole cookie jar. It only ever added Secure to the framework's session_id, which left the more valuable credential exposed: the scaffolded remember-me token is a 30-day bearer credential set by application code, and it went out without Secure on a deployment that had explicitly declared itself TLS-only. An operator flipping a switch named "force secure cookies" means every cookie the process emits. Relatedly, set_cookie(..., {"same_site": "None"}) now adds Secure on its own — browsers drop such a cookie without it, so the old behaviour produced a cookie that silently never arrived.
  • Sign-in no longer leaks which addresses are registered. soli generate auth returned early when no account matched, so the miss path answered in microseconds while a real attempt spent ~100 ms in Argon2id — a timing oracle no error-message wording can close. It also showed a distinct "account locked" message before checking the password, which named existing accounts outright. The miss path now spends the same hashing work, both failures share one message, and the lockout message is held back until the password has verified: the owner learns what happened, someone guessing does not learn the address exists.
  • Per-IP throttling on the credential endpoints. Account lockout cannot see credential stuffing spread thinly across many accounts, and is itself abusable — ten wrong guesses locked any known address out on demand, and every further guess used to slide the 30-minute window forward, making the lockout indefinite. Sign-in, sign-up, and password-reset requests are now limited per source address, returning 429 over the limit — one shared budget across the three (AUTH_ATTEMPTS_PER_IP per AUTH_IP_WINDOW_SECONDS, default 15 per 5 minutes, total rather than each) — and a lockout already in effect is no longer re-stamped. The limiter keys on the peer address, or on the proxy-recorded client only when enable_trust_proxy() is on, so a rotating X-Forwarded-For cannot bypass it.
  • A password policy, in one place. The generated User model had none — a one-character password was accepted at sign-up, and the reset flow enforced its own unrelated 8-character rule. User#password_error now owns the rule (AUTH_MIN_PASSWORD_LENGTH, default 12) and both flows call it, so reset cannot install a password registration would have refused. Resetting a password also drops the remember-me digest: a reset is what someone does when they think the account is compromised, and a cookie that outlives it by 30 days means the reset never evicted the attacker.
  • auth_base_url reads APP_BASE_URL. It was a hardcoded http://localhost:5011 behind a TODO, so a deployment that missed the comment mailed password-reset and confirmation links pointing at localhost — over plaintext HTTP if the host resolved at all. The literal stays as a development fallback.
  • rate_limiter_from_ip rejected its own documented third argument. It was registered with an exact arity of 2 while its body read args.get(2) for the window, so the documented rate_limiter_from_ip(req, limit, window_seconds) failed with "Wrong number of arguments: expected 2, got 3" before the body ever ran — the three-argument form in the docs had never worked. It is variadic now and checks the count itself, and a non-integer window raises instead of silently falling back to 60 seconds.
  • Dependency advisories are a CI gate. cargo audit --deny warnings runs on every push. A published advisory against a transitive dependency is the one class of vulnerability that reviewing our own code cannot surface — nothing in the diff changes and the suite stays green — and Soli sits on a TLS stack, an image decoder, tar/flate2 and several parsers that all take untrusted input directly.

LiveView hardening

  • Closed LiveViews are now released. Nothing ever removed an instance: every session:component pair kept its state, its full last render, and its live-query subscriptions for the process lifetime — and a write to a subscribed collection kept waking views whose browser was gone, each wake costing a handler run, a render and a diff. When the last socket for an instance closes, its subscriptions are dropped and its state is held for two minutes (long enough for a refresh or a network blip to reclaim it), then reaped by a background sweep.
  • A tick and a client event can no longer lose each other's state. Both paths cloned the instance, mutated the clone, and wrote it back, so two frames on different workers resolved last-writer-wins — and the loser's stale render became the next diff base, leaving the browser's shadow diffed against markup it never received. Frames of one LiveView are now serialized, and a frame that finishes after its socket closed no longer re-creates the instance.
  • Events are scoped to the socket that sent them. The server dispatched against the liveview_id in the client's message, so a client could drive another component of its own session — or, with a known session id, another user's view, whose socket then received the patch. A message naming anything other than the sending socket's own instance is dropped and logged.
  • A raising handler reports an error instead of running demo behaviour. A handler error, or an unexpected return value, fell through to the built-in counter/metrics state machine: an app bug looked like "the counter incremented", with the real error only on the server's stderr. It now pushes an error to the client (the message itself only under --dev) and leaves state untouched. Returning nothing means "no state change" rather than demo behaviour. See When a handler fails.
  • Uploads belong to the session that posted them. A stored id was redeemable by anyone who had it, and the store was one 64-slot global map of 8 MiB files — one client could hold roughly 512 MiB and lock every other user out of uploads. Ids are now bound to the uploading session and each session gets at most 8 pending slots.
  • A reconnected view keeps ticking. The tick task was aborted at disconnect but the instance still remembered its interval, so the handler's request looked "unchanged" and was skipped — a clock or dashboard view silently stopped after any network blip. The interval is re-armed when no task is running.
  • Rooms share one LiveView across tabs. The instance key was session:component. A WebSocket upgrade with no Cookie minted a unique sess- id per tab, so two windows of the Field Desk looked like different sessions. Put data-live-room="name" on the mount; the client sends ?room=name and every socket joins room:name:component. The desk tutorial uses field-desk.
  • Chunked uploads and send_update. Files over 256 KiB POST to /live/upload in chunks. send_update("score", { "score": 5 }) writes _components and, when router_live("score") exists, runs the child with event == "update" (Soli's update/2). A bare hash still merges onto the parent.
  • Render errors no longer disclose server paths, and are escaped. A missing template sent the four absolute paths it tried straight into the browser, and the error markup was built by interpolation, so a component name could carry markup into the page. The client-visible message names the component only; the paths stay on the server log.
  • Heartbeat acks are actually sent. The reply was written with a call whose future was dropped, so it never reached the wire (harmless only because the client ignores acks), and the hand-written JSON had drifted from the message type's own shape. One type now owns the shape and the ack is sent without blocking the socket's read loop — as is the event enqueue, which used a blocking send that parked a runtime thread when the worker pool was saturated.

Runtime hardening

  • Runaway recursion and absurd nesting can no longer abort the process. A native stack overflow does not unwind, so the per-request catch_unwind fault isolation never sees it — one deeply nested template expression or a recursive function with no base case killed every worker. Every recursive surface now has a depth guard that returns an ordinary, catchable error instead: source parsing (expressions, statements, match patterns, type annotations), template block parsing and partial/component includes, Soli-to-Soli calls on both engines (call stack too deep …, which try/catch intercepts), and JSON conversion/serialization of runtime-built deep structures.
  • Constructor-body errors are no longer swallowed. The tree-walking interpreter discarded any error raised inside a constructor body, so a constructor that raised produced a half-initialized instance and execution carried on as if nothing happened. Errors now propagate like everywhere else.
  • Fuzzing for the surfaces that take untrusted input. libFuzzer targets cover the lexer+parser, template parse/render, and JSON round-trips; they run nightly in CI with a short smoke on every PR that touches those paths, and crash repros are uploaded as artifacts. An unwrap-count ratchet (scripts/lint_unwraps.sh) freezes the number of .unwrap()/.expect() calls in those modules so it only goes down.
  • A template naming a helper that needs arguments no longer aborts the process. Omitting parentheses (<%= now.to_iso %>) is documented as being for zero-argument callables, but the renderer auto-called every callable it evaluated, handing it an empty argument list. So <%= patch %> ran the patch helper's body with nothing in it — and a helper registered variadic (nothing upstream checks the count) reads args[0] directly, so it panicked, which aborts instead of raising. The renderer now applies the same rule a bare name gets in code: only a callable that genuinely accepts zero arguments is invoked, and one that does not is left alone. The request helpers bounds-check their arguments too, so calling one directly with too few raises … is missing a required argument. Found by the nightly template_parse_render fuzz target.

Built-ins

  • Url class. Parse, build, join, and rewrite URLs without string surgery — component hashes, decoded query params, immutable set_param, percent-encoding helpers.
  • Logger class. Leveled structured logging to stderr with optional fields, text or JSON lines, SOLI_LOG_LEVEL default, and a capture mode for specs.
  • Retry & CircuitBreaker. Exponential-backoff retries (Retry.with_backoff/within) and a per-name circuit breaker with closed/open/half-open state shared across workers and engines.
  • Toml / Yaml classes. Config-format parse/stringify over the same Value model as JSON.
  • Semaphore class. Named process-global counting semaphores with explicit tokens — no-overlap guards without DB round-trips.
  • Money class. Currency-aware amounts over decimals: ISO-4217 minor units, mismatch-detecting arithmetic, lossless allocation, localized formatting. Amounts are quantized to the currency’s minor units on every operation, so m["amount"] is always exactly what Money.format(m) displays; currency codes are case-insensitive and validated.
  • The new classes type-check. Money, Url, Logger, Toml, Yaml, CircuitBreaker, Semaphore and Retry were installed at runtime but unknown to the type checker, so every documented example failed with Undefined variable ‘Money’ unless it was run with --no-type-check.
  • Retry works in views and helpers. It was registered only in the interpreter constructors, so Retry.with_backoff(...) in a .html.slv view or an app/helpers/*.sl raised Undefined variable: Retry while every other new class resolved there.
  • Retry.within backs off. It ignored factor and max_delay and retried at a constant 0.25s, so a long deadline meant hundreds of tight retries against a service that was already down.
  • A half-open circuit admits one probe. Nothing recorded that a probe was already running, so the instant the cool-down elapsed every concurrent caller was let through — a thundering herd onto the dependency that had just failed. A failed probe now re-opens the circuit immediately, and a probe that never reports back cannot wedge it shut.
  • CircuitBreaker.configure accepts fractional reset_after. The Float branch scaled to milliseconds and then applied the number as seconds, so {"reset_after": 0.5} held the circuit open for 500 seconds.
  • A per-key semaphore name no longer fills the store. Slots were created per name and never reclaimed, so a pattern like "import-#{tenant}" permanently filled the 1000-name store — after which every try_acquire for a new name raised, a 500 inside a request handler. Unheld slots are reclaimed at the cap, and the slot survives a drain so a name still keeps the limit its first caller fixed.
  • Retry.within stops at its deadline. It checked the deadline and then slept a full delay, so {"deadline": 2, "base_delay": 1.5} ran for 4.5s — and a 10s deadline could block ~18s at the default max_delay. The sleep is clamped to the remaining budget.
  • A money hash read back from JSON or the database gets its currency normalized too. Normalizing only in Money.new left {"currency": "jpy"} from a payload taking the wrong minor-unit exponent (2 instead of 0) and missing its symbol.
  • A deeply nested JSON body is rejected instead of killing the worker. json_parse — and the request-body parser behind req["json"] — recursed one frame per nesting level with no cap, so [ × 100k aborted the process. A native stack overflow does not unwind, so the per-request catch_unwind never saw it. Past 512 levels the parse returns an ordinary catchable error and the request path treats it as an unparseable body.
  • A success reported while a circuit is open no longer closes it. record_success closed unconditionally, so a call that started before the circuit tripped and finished late reopened the floodgates — defeating the half-open probe. It closes only from half-open now.
  • configure()d circuits survive store pressure. The reclaim predicate dropped exactly the healthy circuits, configured ones included, so boot-time tuning silently reverted to the default threshold under per-tenant naming. When nothing is reclaimable the store refuses to grow rather than exceeding its cap, and an untracked name fails open so a full store cannot start refusing healthy traffic.
  • Semaphore.reset(name) drops a name and every token held on it. release was the only way to free a slot, so a token leaked by a job that raised before releasing wedged that name for the life of the process — the nightly job simply stopped running until a restart.
  • Text-mode log lines cannot be forged. The message and each field value were spliced raw into one line, so a newline in user input (Logger.info(params["email"])) wrote a complete extra record, [ERROR] and all. JSON mode was already safe.
  • Toml.parse no longer leaks serde’s datetime marker. A TOML date came back as {"$__toml_private_datetime": "…"} instead of the timestamp, so config["when"] was a one-key hash — and dates are everywhere in real TOML.
  • Url.build stops losing data. A query hash silently dropped arrays and nested hashes (a filter URL lost its filters); they now expand to the bracket names request params use. username/password are honoured, so Url.build(Url.parse(u)) keeps credentials instead of stripping them, and an unknown key is an error rather than a no-op that hides a typo.
  • DateTime answers is_a?. It was the one universal member still missing, so generic dispatch (if v.is_a?("string") { … }) raised on a DateTime rather than answering false.
  • Url decoding matches request params. + decodes to a space (so Url.params and req["params"] agree on the same query), and an escape that is not valid UTF-8 is kept verbatim instead of becoming an empty string. Url.set_param now rewrites only the named param and leaves every other pair byte-identical — it used to round-trip the whole query, blanking a param it could not decode and turning a + in an untouched value into %2B.
  • A DateTime answers the universal members. inspect, to_s, class, nil?, blank? and present? were hard errors on a DateTime, which resolved only its own registered methods — so the REPL echoing a result raised Cannot access property ‘inspect’ on DateTime for DateTime.now(). Both engines share the one definition.
  • JSON log lines are always valid JSON. A field value with no JSON form was spliced in unquoted, so a function field emitted {"cb":Function} and a non-finite float {"ratio":NaN} — breaking every downstream parser for that line.

Fixes

  • DateTime component accessors agree on timezone. hour and minute used to return UTC while year/day/format used local wall time, so composing parts of one value could print a moment that never existed. Every component accessor now uses the local zone by default. Call t.utc() for UTC components on the same instant, or t.local() to switch back. Equality still compares by instant only. Breaking for code that relied on bare .hour()/.minute() being UTC outside a UTC timezone.
  • Duration.humanize is magnitude-only. It never appends ago (including for negative intervals from Duration.between). Use time_ago for relative past phrasing.
  • Merge updates behaved differently on every SQL adapter. Postgres used jsonb ||, which is shallow and stores a null; SQLite and MySQL use RFC 7396, which merges nested objects and removes a null key. So update({"prefs": {"theme": "dark"}}) destroyed the rest of prefs on Postgres and merged into it elsewhere, and update({"deleted_at": null}) left two different documents. RFC 7396 is now the defined behaviour everywhere: a flat patch stays one atomic statement on Postgres (with the null half corrected), and a patch containing a nested object takes a row-locked read-merge-write. Verified against a live Postgres and SQLite side by side. update_all follows the same rule, and refuses a nested-object patch on Postgres rather than silently dropping keys.
  • An app on a non-public Postgres schema saw every table as missing. table_exists hardcoded table_schema = 'public' while every other schema query resolves through current_schema(), so with a different search_path reads returned empty and the job poller never claimed — both silently. A record created and then immediately looked up raised RecordNotFound.
  • A repointed connection kept using the old database. Adapter pools were cached by connection name alone, so changing a connection's URL handed back the pool for the previous database. All three adapters key on name and URL now.
  • Job retention stopped working once history built up. prune_done scanned a 500-row window, and dead rows are terminal and never pruned — so 500 of them filled the window permanently and no completed job was ever removed again. Job.queues() emptied itself the same way. Both are scoped at the database now instead of filtering a capped list.
  • Cron.every registered schedules that never fired. "90 minutes", "25 hours" and "40 days" each emitted a */N beyond its field's range, which the parser rejects — so the job was registered and simply never ran. "90 seconds" was silently truncated to 60. Each is now an error naming what to use instead; in-range values (including irregular ones like */45, which is how cron works everywhere) still compile, and every one is covered by a test.
  • Grouped-query keys were coerced to numbers. Despite the doc comment saying keys stay text, every column was parsed — so a group key of "00042" came back as 42, and "01" and "1" collapsed into one bucket. Only aggregates are parsed now, on both the column and document paths.
  • Column-mode group_by dropped ORDER BY, LIMIT and OFFSET. Its document-mode twin honours all three, so the same grouped query returned unordered, unbounded rows on a table "…" model and a sorted, paginated set on a document one. The grouped SELECT list now carries SQL aliases so ordering can name a group key or an aggregate.
  • LIKE on a non-text column was rejected by the database. The pattern's placeholder was cast to the column's type, producing $1::text::uuid — and uuid LIKE uuid is not an operator. The pattern binds as text and the column side is cast to text, so a uuid or numeric column can be pattern-matched.
  • db:migrate down could roll back the wrong migration. Only file-backed migrations were considered, so if the newest applied migration's file was missing — deleted, or on another branch — an older one was reverted instead of refusing. It now stops with the orphaned version named.
  • db.execute self-deadlocked inside a transaction on SQLite. It opened a second connection to the same file, and SQLite takes a database-wide write lock — so it blocked on the very transaction that was calling it. It runs on the pooled connection now, resetting the session afterwards for the same isolation.
  • Two visitors joining a LiveView room at once orphaned one of them. Mounting checked the registry and then registered as two steps, so both sockets saw "absent", both rendered, and the second registration replaced the first — leaving the first socket open but no longer in the registry, so it never received another update. Attach-or-register is one atomic step now. Room uploads are also scoped to the session that actually sent the event rather than the instance's creator, so any visitor in a room can complete one.
  • A failed LiveView handler left a button disabled forever. Every other terminal frame clears the pending state; the error frame did not, so a soli-disable-with button stayed disabled with no way to retry.
  • Model.count returned an error string instead of raising. On a table "…" model whose table was missing, the failure came back as "Error: …" where a number belongs — so try { Model.count() } catch never fired and capability probes concluded the table was there. It raises now, and a table declaration on a connection that cannot serve it is refused instead of silently falling back to document storage.
  • module Foo could not be typed into the REPL. module was taught to the result-printing check but not to the block-opener check, so the line was submitted on its own and failed instead of continuing.
  • db:schema:dump / db:schema:load did not read .env. Unlike db:create and db:migrate, so a DATABASE_URL living there was invisible and both reported "needs a SQL connection".
  • The eval harness was missing from a clean clone. An unanchored tasks* rule in .gitignore also matched evals/tasks/, so all twelve task fixtures were untracked and scripts/evals/run.py raised FileNotFoundError. The rule is anchored to the repository root now.
  • soli db:drop could silently drop the default database. Its flag loop ignored anything it did not recognize, unlike db:migrate, so soli db:drop --connection with the value forgotten fell through with no connection and dropped the default — unrecoverably, without a word. db:create, db:drop, db:schema:dump and db:schema:load now reject an unknown or value-less flag with exit 64.
  • A typo'd in-file connection "name" reported success while migrating the wrong database. Only --connection was resolved against the registry; an unknown in-file name made the adapter lookup fail, which was swallowed into "not SQL", so every step took its SoliDB branch — printing Applied while creating collections in the default database and recording the version there, leaving the real target permanently unmigrated. In-file names are resolved at load time now, with the known connections listed on a miss.
  • A panic inside an adapter pinned the worker to the wrong database. with_connection restored the previous target only on the normal path, so a panic unwound past it and the surviving worker read and wrote the secondary database for the rest of its life. The restore is a Drop guard now.
  • A numeric IN list defeated every sibling predicate. It compiled to a chain of disjuncts with no parentheses, and the caller joins siblings with AND, which binds tighter — so Post.where({"id": [1,2,3], "status": "open"}) became id=1 OR id=2 OR (id=3 AND status='open'). The implicit soft-delete and STI scopes were defeated the same way, so soft-deleted rows and sibling STI types leaked into results. Multi-disjunct lists are grouped now.
  • A string .where chained onto a hash .where was discarded on SQL. User.where({"active": true}).where("doc.age >= @min", {"min": 18}) dropped the age condition and returned minors, with no error — the raw filter was mistaken for the SDBQL echo a hash .where also produces. The two are now told apart, and because raw SDBQL is not portable to SQL the mixed form is refused with a message pointing at the hash equivalent.
  • Model.all, all_json, count and delete_all ignored a model's own connection. They tested the ambient default rather than the model's, so a model with connection "reporting" in a SoliDB-default app sent its reads to SoliDB while .where(…).all on the same model correctly reached Postgres. delete_all was worst: it listed keys from one database and routed the deletes to the other. All four are connection-aware now, as is create_many's bulk-insert decision.
  • A slow webhook host could make a job run twice. Webhook delivery ran inline on the poller thread, so a black-holed host blocked the tick past the lease — stalling lease renewal and the cron tick with it — and a second poller then re-claimed the in-flight job through the expired-lease branch and ran it concurrently. Delivery moved to its own thread, holding a worker slot and staying on the in-flight list so its lease keeps being renewed.
  • soli fmt emitted lines that soli lint rejected. The width estimate used when collapsing a single-statement if to a postfix guard had no case for an interpolated string and scored every one of them as 8 characters, so a long guard was collapsed past the 120-character limit. A freshly generated app failed its own lint because of it. Interpolated strings are measured properly now.
  • Non-ASCII values in database.toml were mangled. Environment expansion walked the text byte by byte and reinterpreted each byte as a character, so a password containing é arrived as é. It steps by characters now.
  • The documented --no-default-features build did not compile. With every SQL feature off, one call site had no arm that constrained its success type. That build is the one shipped in the installation and configuration docs, and CI only built --all-features, so nothing caught it.
  • validates uniqueness: raised on every SQL adapter. Its pre-check ran a raw SDBQL query, so declaring it made create/save fail with "Raw SDBQL queries are SoliDB-only" before any row was written. It now uses the portable hash-filter path (and the column path for a column-aware model).
  • Constraint violations arrived as driver text instead of field errors on SQL. Detection matched only SoliDB's HTTP 409, so a duplicate on Postgres/MySQL/SQLite surfaced as something like sqlite column insert row: UNIQUE constraint failed: orders.code in _errors. Each adapter now classifies its own driver error — Postgres by SQLSTATE, MySQL by error number, SQLite by extended result code — and the model layer turns it into { field, message }: "has already been taken", "must reference an existing record" (foreign key), "can't be blank" (NOT NULL), "is invalid" (CHECK). Anything that is not a constraint violation is still reported as-is, so a connection failure cannot masquerade as a validation error. See Validations.
  • Postgres errors said "db error" and nothing else. The driver's Display is that literal string, so every message was our context plus those two words. Errors now carry the driver's real message and DETAIL — which is also where the offending column name comes from.
  • Concurrent increment / decrement and counter caches lost counts on the SQL adapters. Those calls fell back to a read-modify-write there, so two requests both read 5 and both wrote 6. Measured with 8 threads × 25 bumps on SQLite: 53 of 200 increments survived. The arithmetic now happens inside one statement (jsonb_set / JSON_SET / json_set, and SET col = COALESCE(col,0) + ? for a column-aware model), so the row's own lock serializes the bumps — 200 of 200. Counter caches ride the same path, so parent counts stop drifting. A missing field or NULL column still counts as 0, and a non-numeric column is refused by name. Verified concurrently on both SQLite and live Postgres.
  • SQL migration DDL is no longer a global builtin. db.execute and the column-table helpers were registered on every interpreter, so a controller or template could run arbitrary SQL and leave SET / ATTACH / PRAGMA on a pooled connection. They are registered only when running a migration. db.execute uses a dedicated connection (and resets the session afterwards on sqlite::memory:).
  • MySQL DEFAULT strings are escaped for MySQL. Quote-doubling alone let a default of x\', extra INT -- close the literal and become a second column. Backslash, quote, NUL, newline, CR, and SUB are now escaped the way mysql_real_escape_string does.
  • Reserved table names are refused. A migration cannot create, drop, or rename _migrations, _jobs, or _cron_jobs.
  • Column-mode timestamps read as 1970, and saving a record rewrote them. DateTime values are nanoseconds throughout the runtime, but the column-mode reader wrapped the seconds value the parser returns. A datetime column therefore hydrated as 1970-01-01 plus a fraction of a second, and saving the record wrote that wrong date back to the database. Both directions are correct now.
  • create and save on a column-aware model ignored the stored row. A SQL write returns the row as the database holds it, but only the key was read from it — so a column DEFAULT, a database-side trigger, and the stamped created_at/updated_at stayed invisible until the record was read back. Both now adopt the returned row.
  • connection "name" never parsed in a class body. The multi-database binding shipped documented but unusable — the parser recognizes class-body DSL calls by name, and connection was missing from that list, so a model declaring it failed to load with "expected ':' and type annotation for field declaration". Both connection and the new table are registered now.

ORM / database

  • Column-mode encrypts and STI. A table "…" model can encrypt text columns (AES-256-GCM, same as the document path) and share that table across subclasses with a string type discriminator. Subclass queries add type IN (class, descendants); find / find_by on a subclass refuse a row of another type. Boot fails if an encrypted field is missing or not text, or if an STI subclass's table has no type column. Composite primary keys are still refused.
  • First-class attachments. has_one_attached("avatar") / has_many_attached("photos") default to disk (./storage/attachments) or service: "s3" / "solidb". Same attach_ / detach_ / _url methods as uploader. delete purges blobs. A LiveView soli-upload hash attaches as-is.
  • Batched HABTM .includes on Postgres and MySQL. A has_and_belongs_to_many eager load was SoliDB-only. It now runs on the SQL document adapters in two queries regardless of parent count: one for the join-table rows whose owner key matches the parents, then one for the distinct targets those rows point at. Targets attach in the order the join rows came back; a parent with no links gets [] rather than null; and a dangling join row (a link to a deleted target) contributes nothing instead of a null hole. includes_count uses the same path, so it counts join rows for HABTM. through: includes, .having and .join landed on SQL later in this same cycle — see SQL query surface.

Docs

  • A documentation audit against the running binary — and it found the docs asserting things the language does not do. Every documented CLI command, environment variable, internal route, builtin name and docs link was checked against the code or exercised against the binary. The corrections: the testing pages documented an assertion API that does not exist (assert_equal, assert_true, assert_false, assert_nil, assert_not_nil, each with a trailing message, returning a { passed, message, expected, actual } hash) — the real vocabulary takes values only, raises on failure, and is now documented in full; soli run file.sl was never a valid invocation (it is soli file.sl) and appeared in ten places; the live-reload page documented a SOLI_ENV variable nothing reads and a ./dev.sh the template never shipped; the error-pages page documented a --no-dev flag that does not exist (production is the absence of --dev); DateTime gained .add_minutes() in the docs and lost .add_weeks() / .add_months() / .add_years(), which were never implemented; and the Math namespace was fiction — math operations are methods ((-5).abs(), [3, 7].min(), (16).sqrt()), with no trigonometry, logarithms or exponentials in the language at all.
  • Five docs links served a JSON blob instead of a page. /docs/jobs, /docs/authorization, /docs/core-concepts/models, /docs/language/linting and /docs/core-concepts/testing all fell through to the catch-all route. Every internal /docs link in the site now resolves, and docs search no longer offers entries for API that does not exist (twelve Math.* functions and two pointing at a removed page).
  • The SQL adapter environment variables reached the Configuration page. SOLI_DB_ADAPTER, DATABASE_URL and SOLI_DB_POOL_SIZE were in the markdown reference but not on the page mirroring it, whose Database section listed only the SoliDB variables. The capability matrix also regained three rows it was missing (dev bar / dev_queries(), soli db:create / db:drop, and raw SDBQL).
  • The hash-filter operator symbols are documented. >, >=, <, <=, ==, =, != and <> have always been accepted alongside gt / gte / … but appeared in neither the docs nor the unknown-operator error. Both now list them. See Hash filter operators.
  • A Live Field Desk tutorial. Nested live_component assigns, soli-upload, in-socket tabs, debounce, click-away, hooks, and JS commands — with the widget running on the post, plus the hash .where / jobs snippets you would ship next to it.
  • Dedicated PostgreSQL, MySQL, and SQLite pages. Adapter-specific notes — URL, create/drop, JSON storage, indexes, jobs, schema dump, column-mode types, honest limits — live next to Multiple Databases in the sidebar, instead of being a section of that one long page.

SQL query surface

  • Portable hash .where comparisons, IN, LIKE, and OR. The hash form used to be equality-only; anything richer had to be raw SDBQL, which SQL adapters refuse — and a { "gt": 10 } silently became ==. The hash now compiles through a structured IR on SoliDB, SQL document tables, and column-aware models: { "total": { "gt": 100 } }, { "id": [1, 2, 3] }, { "email": { "ilike": "%@x.com" } }, { "or": [{ "state": "draft" }, { "state": "open" }] }. The string form remains for expressions the vocabulary cannot express.
  • Filtered .includes on SQL. .includes("comments", { "visible": true }) and .includes("comments", { "where": { "n": { "gt": 1 } } }) apply the same hash vocabulary to the related rows — after the batched fetch on document tables, pushed into the IN query on column-aware models. A raw string filter on .includes stays SoliDB-only.
  • soli db:schema:dump / soli db:schema:load. Dump writes db/schema.sql — dialect SQL for every user table and index, plus the applied migration versions in a header — so a fresh database can be built without replaying every file. Load runs that SQL and records the versions in _migrations. Both accept --connection NAME.
  • Column-mode through: / HABTM includes, .join, and .having. A table "…" model can eager-load has_and_belongs_to_many and has_many through: (the join / intermediate table must also be column-aware), filter parents with .join("comments") (correlated EXISTS), and filter groups with .having("n > 5").
  • through: eager loading on SQL. .includes on a has_many through: batches into three queries whatever the parent count: the intermediate rows for these parents, the targets those rows point at, then grouping in Rust. It turned out through: eager loading was refused at the builder, so it had never worked on SoliDB either; the check moved to execution, where the AQL shape still declines it and the SQL path serves it.
  • .join("comments") compiles to a correlated EXISTS rather than a real join, so a parent with two matching children is returned once, not twice, and the SELECT doc shape is untouched. A child-side filter rides inside the subquery in the portable hash shape.
  • .having("n > 5") compiles to a HAVING clause. The supported shape is one comparison of a group key or aggregate alias against a number; the aggregate expression is repeated rather than its alias, since Postgres does not accept an alias there. An unknown alias is refused listing the ones the query emits, and anything richer is refused naming the supported shape rather than passed through as SQL.

SQL escape hatches

  • Model.find_by_sql(sql, binds?) — the escape hatch for a query the portable surface cannot express. The raw_sql capability flag had been set to true on the SQL adapters since they shipped while nothing exposed raw SQL at all; it finally means something. Binds are positional and always bound, never interpolated, so a value that looks like SQL stays a value. A single doc column hydrates as documents; any other shape becomes a hash per row. Raises on SoliDB, pointing at Model.query with SDBQL.
  • create_many is one statement per chunk on SQL, instead of one statement (and on SQLite one transaction) per row. Chunked at 500 rows — each row takes two binds and Postgres allows 65535 per statement — and wrapped in one transaction so a partial failure cannot leave half a batch behind. Re-running upserts, like single-row insert. The per-item attr_accessible filter still runs on every row: bulk insert would otherwise be a perfect mass-assignment bypass.
  • pluck / select push their projection into the SELECT list on column-aware models, so a projection on a wide table reads two columns instead of fifty. The primary key is always included so the row stays identifiable, and a field that is not a real column falls back to the client-side projection.
  • A column added by ALTER TABLE no longer needs a restart. When a query names a column the cached schema does not have, the column path re-introspects that one table and retries once; a genuine typo still reports the original "unknown field" error with the real column list.

Existing databases

  • Column-aware models reached association parity. A model bound to an existing table with table "…" could only do single-row and scalar work. Now it also does batched eager loading for belongs_to, has_many, has_one and includes_count — one query per association whatever the parent count, using column IN (…) over the real foreign-key columns. Measured in a live app: 3 parents with includes("books") costs 2 queries, not 4, and a parent with no children gets [] rather than null. Both sides must be column-aware; joining a real column to a JSON field is refused with a message naming both models instead of quietly returning nothing.
  • group_by and bulk writes on real columns. Grouping with sum/avg/min/max/count (a non-numeric aggregate is refused by name), plus delete_all / update_all, which stamp updated_at when the table has it and never rewrite the primary key.
  • soft_delete works when the table has a deleted_at column — the scope becomes an ordinary IS NULL / IS NOT NULL filter. Declaring it on a table without that column fails at boot naming the missing column, instead of the declaration being rejected outright. Counter caches also work now, following from the atomic column increment.
  • Still out on column mode: composite primary keys.

SQL dev tools

  • The dev bar, dev_queries(), and N+1 detection now work on the SQL adapters. Only the SoliDB path wrote to the per-request query log, so on Postgres/MySQL/SQLite the DB panel was empty, timings were missing, and the N+1 badge, assert_no_n_plus_one and soli test --fail-on-n1 could never fire — the framework's own N+1 guard was blind on three of four backends. Every statement now records its SQL, its binds (numbered the way $1 / ? appear in it) and its duration, plus a Db span so the flamegraph shows database time. Verified in a live --dev app: the badge reads 5q, the panel lists the real SQL, and a deliberate per-row lookup reports "N+1 DETECTED · 2 TEMPLATES". The Prometheus DB-time counter is fed on this path too, so production gains SQL timings even with the dev log off.
  • soli db:create / soli db:drop. SoliDB creates its database on first use; a SQL server does not, so pointing DATABASE_URL at a database nobody created failed at boot with a driver error. db:create runs CREATE DATABASE (through the postgres maintenance database on Postgres, a db-less connection on MySQL) or creates the SQLite file and its parent directory; db:drop removes it, including the -wal/-shm sidecars on SQLite, which would otherwise resurrect committed data into the next file of the same name. Both accept --connection NAME.

SQL performance

  • index declarations now work on the SQL adapters — and the planner uses them. A document table had no indexes at all: index sync reconciled declarations through SoliDB's HTTP index API, which refuses on a SQL connection, and nothing else issued index DDL. Every .where({ status: … }) was a sequential scan plus a per-row JSON extract. Now index "status" creates an expression index on the JSON field: ((doc->>'status')) on Postgres, ((doc ->> '$.status')) on SQLite, and on MySQL a generated STORED column plus an index on it, because MySQL cannot index a JSON extract directly. Multi-field and unique: declarations work the same way, and reconciliation stays idempotent by name.
  • String equality compiles to the expression the index holds. An index the planner ignores is dead weight, so .where({ "status": "open" }) now compares on the JSON text extract — exactly what the index stores. Numbers and booleans keep JSON comparison, which no expression index covers — and which means different things per backend: Postgres compares jsonb numerically (10 matches a stored 10.0), MySQL and SQLite compare the JSON representation (it does not). The docs say so rather than implying otherwise.
  • The job queue indexes itself. On first enqueue a SQL connection gets indexes on state, run_at and priority (plus next_run_at/enabled for cron). The claim query runs every poll tick and used to scan every job ever enqueued — failed and dead rows are kept on purpose, so that table only grows.
  • Migrations can index a document field with a doc. prefix: db.add_index("posts", ["doc.status"], { "unique": true }). And fulltext/bloom/cuckoo index types plus vector_index/geo_index are now reported as skipped on SQL instead of failing with a confusing adapter error.

Migrations

  • Migrations can build real column tables, portably. Column-aware models could read and write an existing relational schema, but nothing in Soli could create one — migrations only produced _key + doc document tables, so a greenfield column schema had to be built with psql, the sqlite3 CLI, or another framework's tooling. Now db.create_table("orders", { "id": "pk", "amount": "decimal(10,2)", "timestamps": true }) declares real columns, with add_column, drop_column, rename_column, rename_table, add_index, drop_index, and execute(sql) alongside. See Migrations on the SQL adapters.
  • One migration, three backends. The types are Soli's (pk, uuid_pk, string(n), text, integer, bigint, float, decimal(p,s), boolean, date, datetime, json, uuid, binary) and each adapter renders its own SQL. The rendered names are chosen so introspection reads the table back as the same Soli types — a table created this way is always one a column-aware model can map. Portability details are handled rather than papered over: MySQL parses an inline REFERENCES and then ignores it, so foreign keys go at table level there; MySQL takes no IF NOT EXISTS on CREATE INDEX and needs the table on DROP INDEX; SQLite cannot add a UNIQUE or NOT NULL-without-default column to an existing table, and says so with the way around it. A composite primary key is refused at parse time, because column mode could not map the result.
  • A migration can declare which database it belongs to. Put connection "analytics" as the first non-comment statement and soli db:migrate up places it correctly — no --connection flag, and no chance of running it against the wrong schema. A line inside a string or after def is ignored; a second declaration is an error. Each connection tracks its own versions. --connection NAME is a filter: migrations declaring another database are held back and reported as skipped.
  • Column tables are still yours. A model never issues DDL in column mode — no auto-create, no index sync, no implicit ALTER. Migrations are the one place Soli changes a column table, and only where you wrote it.

SQLite adapter

  • SQLite is a first-class adapter. SOLI_DB_ADAPTER=sqlite with DATABASE_URL=sqlite://db/app.sqlite3 (or adapter = "sqlite" on a named connection) runs the same Model surface as Postgres and MySQL — document CRUD, hash .where, order/limit/offset, count/exists, aggregates, group_by, bulk writes, soft-delete, batched .includes (HABTM included), Model.transaction, and SQL migrations. There is no server to install, start, or credential: a connection is a path. The client is compiled into the binary, so the host needs no libsqlite3. See SQLite specifics.
  • Column-aware models work on SQLite too. table "orders" maps a model onto an existing SQLite schema's real columns, introspected with PRAGMA table_info. Because SQLite enforces no types, Soli reads the declared type to decide how to convert and reads each value by what it actually is — so a DATETIME holding a unix timestamp (seconds or milliseconds) comes back as a date, exactly like stored text does. An INTEGER PRIMARY KEY is recognised as database-generated (it aliases the rowid, with or without AUTOINCREMENT); a key of any other type must be supplied.
  • Background jobs and cron run on SQLite. Postgres claims due work with SKIP LOCKED and MySQL with a claim token; SQLite takes the database write lock (BEGIN IMMEDIATE) for the length of the claim, which is exclusive by construction. Leases, retries, backoff, and single-winner cron firing behave identically.
  • Chosen defaults, and the honest caveats. Every connection opens in WAL mode with a 10-second busy timeout and foreign keys on; a transaction is BEGIN IMMEDIATE so it cannot fail to upgrade a read into a write; sqlite::memory: is pinned to one pooled connection (a second would be a second, empty database); a missing parent directory for the file is created. The trade-offs are SQLite's own: one writer at a time (a write-heavy multi-process app belongs on Postgres), and no exact numeric type — a DECIMAL column has NUMERIC affinity, so SQLite stores the value as a REAL and 19.90 reads back as 19.9 (Postgres and MySQL keep the scale; declare the column TEXT if the exact text matters).
  • Feature flag. sqlite is on by default and included in the sql alias; drop it (or build only what you need) for a smaller binary.

Existing databases

  • Column-aware models — point Soli at a database that already exists. The SQL adapters were a document store on top of SQL (every table _key + doc), so a real relational schema failed on the first query. A model can now bind to an existing table with table "orders" and read/write its real columns. See Column-aware models.
  • Schema by introspection. At boot Soli reads information_schema once per table and caches columns, types, nullability, and the primary key — including whether the database generates it (BIGSERIAL/IDENTITY/AUTO_INCREMENT), so inserts leave it alone. created_at/updated_at are stamped only when those columns exist.
  • Full CRUD. find (Int or String key), find_by/first_by, hash .where (including {field: null} → IS NULL), order/limit/offset, count/exists, sum/avg/min/max, create/save/update/delete, pluck/select, and Model.transaction.
  • Never issues DDL, and fails loudly rather than silently. Column mode maps to a schema Soli does not own: no auto-create, no index sync, no implicit ALTER. A missing table, composite primary key, keyless table, or solidb connection fails at boot naming both. Writing an unknown field, filtering an unsupported column type, or summing a text column errors with the column and its type. encrypts (text columns) and STI (string type column) later joined the supported set; composite primary keys are still refused. Doc-store models on the same connection are untouched.

Testing

  • soli test drops its worker databases when the suite finishes. The teardown truncated every collection and left the databases in place, so a machine running many projects accumulated one empty *_spec database per worker per app, forever. They are dropped now (issued in parallel, still serialised server-side) and the next run recreates them from the template. The teardown cost is close to the old truncate on a small app and grows with the collection count. SOLI_TEST_KEEP_DB=1 restores the truncate behaviour when the tight test loop matters more than the leftovers; SOLI_TEST_FRESH_DB=1 is only meaningful in combination with it now.

Background jobs

  • Jobs now run inside the Soli process. The queue is a _jobs collection on your default connection, so background jobs work identically on SolidB, PostgreSQL, MySQL, and SQLite — apps on the SQL adapters could not run jobs at all before. A poller claims due work atomically (Postgres FOR UPDATE SKIP LOCKED, MySQL a token claim, SQLite the database write lock, SolidB an If-Match compare-and-swap), so several soli serve processes can share one queue without double-running a job. Execution happens on the worker pool, never on a web worker, so a slow handler can no longer delay request serving. See Jobs & Cron.
  • Soli owns retries and crash recovery. Exponential backoff from 5s, doubling, capped at 1h with per-job jitter. attempts increments at claim time, and a running row whose lease expires is reclaimed — which is how work survives a killed process. Completed rows are pruned after SOLI_JOBS_RETENTION_SECS; failed and dead rows are kept for inspection.
  • Cron is evaluated and fired by Soli. Cron.schedule/list/update/delete, the Cron.every/hourly/daily_at/weekly_at builders, and static cron all keep working; a compare-and-swap on each schedule's next_run_at means exactly one process fires each occurrence. Invalid expressions are now rejected when you declare them — with a message naming the six-field shape — instead of silently never firing.
  • XJob.perform_now(args). Runs a handler inline with no queue, worker, or database. This is the documented-but-missing method that makes job logic unit-testable.
  • Webhook.* is a built-in job type. Soli delivers the outbound POST itself, with the same X-Webhook-Signature / X-Webhook-Event / X-Webhook-Delivery headers receivers already verify — now with retries, on every adapter.
  • Upgrading from the SolidB-driven engine. The callback endpoint and its environment variables are gone, failed jobs are now retried, and a SolidB queue that still holds jobs must be drained first. See Breaking changes — background jobs for the full list.
  • New env knobs. SOLI_JOBS_POLL_MS (1000), SOLI_JOBS_LEASE_SECS (60), SOLI_JOBS_MAX_RETRIES (3), SOLI_JOBS_RETENTION_SECS (604800). soli new now also creates app/jobs/.
  • Standalone worker and queue dashboard. soli jobs (alias soli worker) loads the app, claims _jobs, and runs them with no HTTP listener — pair with SOLI_JOB_WORKERS=0 on soli serve to scale workers separately. soli jobs list / retry / cancel inspect the queue. /__soli/jobs is the same cancel/retry UI in --dev and, in production, when you set SOLI_JOBS_USER + SOLI_JOBS_PASSWORD (HTTP Basic) or SOLI_JOBS_TOKEN (Bearer). Unconfigured production 404s. Job.retry(id) re-queues a failed or dead row and keeps attempts / last_error.
  • A webhook whose delivery thread could not be spawned was stranded forever. The failure path returned the worker slot but left the job on the in-flight list, so lease renewal kept extending it indefinitely — the row sat in running and nothing ever reclaimed it, the exact opposite of the intended "let the claim expire and retry". The claim is now released properly.
  • --dev polls the queue every 5s instead of every second. soli new scaffolds app/jobs/, so every dev app starts the engine whether it uses jobs or not — and each tick is a lease-renew, a cron check and a claim against the database. With several dev servers open on one shared database that idle chatter was the bulk of its traffic. Production is unchanged at 1000 ms, soli jobs (a process started to run jobs) always polls at the configured rate, and setting SOLI_JOBS_POLL_MS overrides the dev pacing too.
  • Retention and the queue filter no longer scan the whole table. Pruning counted the doomed rows with a SELECT before deleting them, materialising every row's full document just to report a number the DELETE already returns; and the dashboard's queue list selected one document per non-terminal job to collect a handful of distinct names, where a GROUP BY does. On a backlog of hundreds of thousands of jobs each of those pulled the table into memory.

ORM / database

  • SQL adapters (Postgres + MySQL, Phase 3). SOLI_DB_ADAPTER=postgres or mysql with DATABASE_URL runs Model CRUD against JSON document tables: create/find/update/delete, hash-style .where, order/limit/count/exists, aggregates, bulk writes, soft-delete, pluck, and Model.all. Phase 3: eager .includes batching (belongs_to/has_many/has_one, and HABTM — see above), multi-row group_by, and soli db:import (SoliDB → SQL). Graph and pgvector stay SoliDB-only (through: includes and .having arrived later in this cycle). See docs/sql-adapter-design.md.
  • SQL Model.transaction. On Postgres/MySQL the block holds one pool connection (BEGIN/COMMIT/ROLLBACK). Nested blocks join the outer transaction. Keep blocks short.
  • soli db:migrate --connection NAME. Run up/down/status against a named connection from config/database.toml (SQL secondaries).
  • A nested-object update inside a Postgres transaction committed it early. Merging a patch that contains a nested object needs a read-modify-write under a row lock, and that path issued a raw BEGIN/COMMIT — on the very connection the enclosing Model.transaction was holding. So transaction(fn() { user.update({"profile": {"theme": "dark"}}); other.save(); raise "boom" }) committed the earlier writes and the rollback did nothing; on the error side its ROLLBACK discarded writes the caller had already made. It nests with a SAVEPOINT now, and only uses a real transaction when there is none to join.
  • Ordering a grouped query sorted text, not numbers. Grouped rows are selected as text so all three dialects return one shape, and a bare alias in ORDER BY binds to that output column — so Order.group_by("status").order("n", "desc").limit(3) ranked a count of 9 above 100 and "top N" returned the wrong rows. Ordering now targets the underlying expression: the aggregate itself, or the table-qualified group column.
  • Grouped keys keep their column's type. On a columnar model, a group key of "00042" used to parse to 42 (collapsing "01" and "1" into one bucket), and the fix for that turned every key into a string — so Order.group_by("year") on an integer column returned {"year": "2024"} and broke arithmetic and == 2024 comparisons. The schema decides now: numeric columns yield numbers, text columns keep their exact text.
  • Model.paginate reported an empty page instead of raising. The query layer signals failure with an in-band "Error: …" value, which count now raises on but paginate still read as zero — a columnar model whose table was missing came back as total: 0, total_pages: 1 with an error string in records, and no catch ever saw it. Both the count and the records query raise now.

Auth / security

  • soli generate oauth github|google. OAuth client scaffold (requires generate auth): OauthIdentity, provider services, /auth/:provider + callback, CSRF state, find-or-create user and session login. See OAuth Client.

Operations

  • Production observability — structured JSON logs and OpenTelemetry traces. SOLI_LOG_FORMAT=json emits one NDJSON object per request (and per production error on stderr) so Loki / CloudWatch / Datadog can ingest them without a parser; detail channels become nested arrays and secret-bearing binds stay redacted. SOLI_OTEL=1 (or any OTEL_EXPORTER_OTLP_* endpoint) turns on distributed tracing: inbound W3C traceparent is honoured, every response echoes one, and the same hierarchical span tree the dev-bar flamegraph builds (middleware, action, views, DB, HTTP) is exported over OTLP/HTTP JSON on a background thread so request latency is never blocked on the collector. Standard knobs work as expected (OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, OTEL_SDK_DISABLED). Pair both for log↔trace joins via shared trace_id. See Observability.
  • Production worker default is 2, not one-per-core. With APP_ENV=production (or prod) and neither SOLI_WORKERS nor --workers set, the HTTP pool opens 2 interpreters so a many-core box does not multiply baseline RSS by core count. Raise it for throughput; the boot banner says how.
  • Slim Cargo features for lower RSS. paseto, postgres, mysql, and sqlite are optional (on by default with embedding/llm/codegraph). Rebuild without the clients you do not need: cargo install --path . --locked --no-default-features --features embedding,llm,codegraph. A disabled SQL adapter fails boot with a rebuild hint; the Paseto class is absent when the feature is off. See Keeping memory low.

v1.29.0 — 2026-08-09

Dev tools

  • soli update docs [folder]. Refresh agent guides (CLAUDE.md, nested guides, AGENTS.md, .claude/) and the bundled language docs under docs/ from the templates embedded in the installed soli binary. Use after upgrading soli so existing apps stay current. Overwrites those paths; project-local notes should live elsewhere. See AI Agents.

Docs

  • Scaffold / agent guides match the real CLI. No more recommended soli generate controller|model|migration (those subcommands do not exist). Recipes and /soli-resource use soli generate scaffold and soli db:migrate generate. Migrations are documented as soli db:migrate up. Scaffold output paths document the real controller E2E spec location.

v1.28.0 — 2026-08-05

Auth / security

  • PASETO v4 tokens. A Paseto class for local encrypt/decrypt and public sign/verify, with PASERK keys. No negotiable algorithm. Failures raise. See PASETO Tokens.

ORM / database

  • Optional native SoliDB driver (--features solidb-driver, SOLI_DB_DRIVER=1) — MessagePack over pooled TCP for model CRUD and queries. Auth prefers SOLIDB_API_KEY. Document get and SOLI_DB_NO_QUERY_CACHE work on the driver path.

Performance

  • render_json uses sonic-rs (same path as JSON.stringify). Future resolution no longer rebuilds arrays of row hashes without futures. Pure-JSON bench ~+16%.

Docs

  • Benchmarks re-measured with the native driver and the JSON path fix; prose tightened. Soli leads or ties every HTTP row vs the previous session that lost the three writes to Phoenix over HTTP.

v1.27.2 — 2026-08-03

Fixes

  • The Windows build stopped compiling, and took the whole v1.27.1 release with it. The binary matrix is fail-fast, so the Windows job failing cancelled the arm64 builds and skipped both the release publish and the Docker image — v1.27.1 never shipped. The cause: soli cloud and soli env down read their target server from deploy.toml, but the module holding that parser is Unix-only because the rest of it is built on ssh2, so reading a config file failed to resolve on Windows. Parsing deploy.toml now lives on its own, apart from the SSH machinery that still needs a Unix host. Nothing changes on Unix, and soli deploy remains Unix-only.

v1.27.1 — 2026-08-03

Dev tools

  • soli cloud — immutable releases with a mutable alias. soli cloud deploy builds an artifact, lands it in releases/<app>/<id>/ — never modified after it lands — repoints sites/<app> at it, asks the proxy to deploy, waits for the health check, and only then moves the alias. soli cloud rollback is therefore repointing a symlink: no rebuild, and the bytes it returns to are provably the ones that were serving before. Plus releases and --dry-run, which prints the same plan a real deploy executes rather than a second description of it. Servers come from the existing deploy.toml. A failure after the symlink moves is reported with the release still serving and the command to go back — and is deliberately not rolled back automatically, because that is a second uncontrolled change on top of the first. See Immutable Releases.
  • soli env — a running environment per branch. soli env up --branch feat/cart creates a git worktree, writes it an .env, creates and migrates its own SoliDB database, seeds it, and links it into Soli Proxy's sites directory so it comes up on its own subdomain. soli env down reverses all four, in the order that matters — it asks the proxy to stop the app before unlinking, because removing the directory alone leaves the process running and holding its ports. Also list and url. Configure it with a [preview] section in deploy.toml; --server <name> targets a remote proxy. See Preview Environments.
  • Preview domains are flat, and that is deliberate. An environment is reachable at <branch>--<app>.<base>. DNS wildcards and the proxy's SNI resolver both match exactly one label deep, so a flat name lets one *.<base> record and one wildcard certificate cover every app and branch, where a nested <branch>.<app>.<base> would need a pair per app. Branch names are sanitised into a DNS label and anything over 30 characters is truncated with a hash of the full name, so two long task/… branches sharing a prefix cannot collapse onto the same domain.
  • The preview env template is guarded against pointing at production. The template named by env_template is copied into the worktree and then overlaid with the generated SOLIDB_DATABASE, so a value in the template can never survive into the preview. It defaults to .env.preview.example rather than .env.preview because the generated file sets APP_ENV=preview and Soli layers .env.preview over .env — a template with that name would be checked out into every worktree and silently win. soli env up refuses to start if it finds one, and refuses to fall back to the app's own .env.
  • soli serve --assets DIR — a static folder can reach the images that live beside it. In file mode the served folder is the whole static root, with no public/ sub-root the way an app has one — so soli serve www/docs rendered every page and 404'd every picture, because the pages embed /images/… and those files sit in www/public/images/. --assets mounts an extra read-only root, repeatable and consulted in order, but only after the served folder has failed to match (including its nice-URL extension probe), so nothing you mount can shadow a real page. An assets root is data, not a second site: only an exact file answers, with the same MIME type, ETag, 304 and Range handling as any other file — a folder gets no generated index, Markdown is not rendered, and a .slv/.erb is neither executed nor dumped as source, so pointing the flag at a folder you did not audit widens what is readable by one directory tree and never what is executable. Each root is canonicalized and jailed separately, so dotfiles and escaping symlinks stay 404. Paths resolve to absolute before the server daemonizes, a typo fails fast, and an MVC app — which already serves public/ — warns that the flag is ignored. Under --dev each root is watched like the served folder, so editing a picture there reloads the page embedding it. See Extra asset roots.
  • soli serve --strict-port. By default a taken port makes the server scan upward for a free one, which is right interactively and wrong under a supervisor: Soli Proxy health-checks the port it assigned, so an app that quietly moved to port + 1 reads as unhealthy and is quarantined after three such failures — a port race presenting as a broken deployment. --strict-port exits instead.

Fixes

  • HTTP.* failed intermittently against HTTP/2 APIs — every other page load, same URL. A parallel HTTP.get_all_json to an h2 host returned {"error": "Request failed: error sending request for url (…)"} for some URLs while the others succeeded, so a controller reading responses[1] 500'd about half the time. The user-facing HTTP client is process-wide with a per-host connection pool, but each request was driven by a throwaway single-thread runtime dropped on return. HTTP/1.1 hides that — the socket dies with its runtime and the pool opens a new one — while HTTP/2 keeps one multiplexed connection per host and hands it to concurrent requests, so a connection whose runtime is gone is still handed out and fails instantly with runtime dropped the dispatch task. User HTTP now runs on one long-lived runtime that owns the pool; the future still runs on the calling thread, so the dev query log and request context are unaffected. Connection reuse across calls is the bonus — a second call to an API you just used skips the TCP and TLS handshake.
  • A failed HTTP request now says what went wrong, not just that it did. Display for a reqwest error stops at the top level, so a transport failure read error sending request for url (…) and threw away the sentence naming the cause — dns error, connection closed before message completed, invalid peer certificate. Every HTTP.* error and the dev HTTP log now carry the whole cause chain.
  • A browser that cannot run no longer fails the whole browser suite — and now says why it could not. Discovery stopped at the first browser on PATH and the driver retried that one binary five times, so a machine with a snap-packaged Chromium and a working Chrome or Edge failed every browser spec: snap run refuses a session whose user cgroup it does not recognise (no systemd --user, no D-Bus) and exits before Chromium starts. Every candidate found is tried now, in preference order, and one that will not start hands over to the next; google-chrome and google-chrome-stable resolving to the same file count once. SOLI_CHROME_PATH still pins a browser rather than merely preferring it — silently driving a different one than you asked for would be worse than failing.
  • A failed launch quotes what the browser printed before dying. Its stderr went to /dev/null, so the error read the browser exited during startup (exit status: 1) and nothing more — discarding the one line that named the cause. The last lines are kept and shown under each candidate. They are drained on a thread because an unread pipe fills and stops the browser dead (Edge logs a D-Bus error every few frames on a host with no session bus), and detached rather than joined, since Chromium's forked children inherit the pipe and EOF can lag well behind the process that was killed.
  • One browser can no longer soak up the whole launch budget. Retries are bounded per binary at 25 seconds, just over the 20-second readiness timeout: a browser that exits immediately is cheap and still gets all five attempts, while one that never publishes a DevTools endpoint gets a single shot instead of five — 100 seconds of waiting before the next candidate would have been tried.

v1.27.0 — 2026-07-31

Dev tools

  • soli serve now works on any folder, not just Soli apps. Pointed at a directory with no app/controllers/ and no config/routes.sl, it used to refuse to start with Invalid MVC structure; it now serves that directory as a website. Files go out with MIME type, ETag and Range; .md renders as a styled page; .slv/.erb templates execute with the folder as the views root (locals path and params, no layout); every folder gets a generated index with its README.md rendered above the listing; and --dev live-reloads on edit. Detection is automatic, with --static and --app to pin it either way — --app keeps the old error verbatim. See Static & Markdown Server.
  • Media and source files open inside the site instead of navigating away. Clicking a .jpg in a listing used to hand the browser the raw bytes — you lost the tree and got a picture on a blank background. Images, video, audio and PDFs now open in the shell with their breadcrumb, sidebar, size and type; text and source files render in the page (.sl/.slv lexer-highlighted) up to 512 KB; anything unshowable offers a download. The raw bytes stay reachable, which is what keeps every <img> embedded in a Markdown page working: a click is a navigation (Sec-Fetch-Dest: document) and an <img> is a subresource, so the two get different answers. curl sends neither header and gets the file; ?raw forces it for anything.
  • Markdown pages gained an “On this page” rail. Every ## and ### gets a slug anchor and a right-hand entry that marks the section you are reading as you scroll. Hidden below 1180px, and skipped entirely for a document with fewer than two headings — a one-line table of contents is furniture, not navigation.
  • Index documents replace a folder's listing. index.html, index.htm, index.md, index.slv and index.erb are each served by their own rule — HTML as-is, Markdown rendered, templates executed. Previously only index.html counted, and index.md was treated as a README. A README.md still renders above the listing: it describes a folder, an index replaces it.
  • Generated pages carry a tree(1) sidebar whose rail lights along the current path. Real box-drawing glyphs, filter on / with arrow-key navigation, night/day solar palettes following prefers-color-scheme with a toggle that overrides it. Fenced soli blocks are highlighted server-side by re-lexing them with the language's own lexer; other languages render as plain monospace rather than being guessed at. The stylesheet and script are compiled into the binary, so the pages make no network request and the mode works offline. The sidebar renders server-side and every row is a real link — it works with JavaScript disabled — and it scrolls to centre the file you are reading, so on a deep tree the lit rail is actually in view.

Auth & security

  • File mode binds loopback and hides dotfiles. Serving a directory you happened to cd into should not publish it to the LAN, so file mode defaults to 127.0.0.1 (SOLI_HOST=0.0.0.0 still opts in); MVC apps are unchanged. Any path segment starting with . returns 404 — not 403, which would confirm the file exists — so .env, .git/ and .ssh/ are invisible and never appear in a listing, and the check runs on the canonicalized path so a symlink pointing at one is hidden too. The mode never loads .env, never configures a database and never executes a .sl file; File.* and Image.* are jailed to the served folder.

Fixes

  • Static file extensions now match case-insensitively. logo.PNG was served as application/octet-stream, so the browser downloaded it instead of displaying it — which reads as a broken image, not as a naming rule. .md and .markdown also joined the MIME table as text/markdown.

v1.26.3 — 2026-07-30

Fixes

  • soli fmt rewrote controller hook assignments into calls, silently deregistering every filtered hook. this.before_action(:index) = fn(req) { … } came back as this.before_action(:index, fn(req) { … }) — which still parses and still runs, but registers nothing, because the hook registry scans raw source for the literal ") = " followed by fn. The failure is invisible where the damage happens and surfaces later as a missing instance variable. A whole-project format run on a real app killed 54 filtered hooks across 16 controllers — auth, plan and guest gates throughout. The parser desugars foo(args) = value into foo(args…, value), so both spellings reach the printer as one indistinguishable node; the source still tells them apart, because the text between the last written argument and the trailing value reads ") = " where a plain call has ", ". The printer now recovers the original spelling from that gap. Comparisons inside an argument list (eq_check(a == b, c), cmp(a, b >= c)) are not mistaken for it.
  • soli fmt emitted a postfix if after a multi-line @sdbql{ … } block or [[ … ]] raw string, producing a file that would not parse. The guard-clause rewrite collapses if cond { stmt } to stmt if cond, putting the keyword after the value — fatal when the value prints across lines, since the if then lands after the closing delimiter. This broke soli serve boot and made soli test report “Test server failed to start”. Query blocks and raw strings are copied verbatim out of the source — escaping a multi-line query would collapse it into one 200-plus character line — so their newlines survive any layout choice, and the rewrite now refuses when the inner statement or the condition carries one. A single-line @sdbql{ … } still collapses to postfix, which parses fine.

Dev tools

  • soli lint no longer walks into locale files. Apps that keep translations in Soli rather than YAML — app/helpers/locale_fr.sl returning a hash of full sentences, one key per line — got hundreds of style/line-length hits per language, burying every genuine finding under noise nobody would act on. A directory walk now skips a file under a directory named locales/, or whose stem is locale_<tag> / <tag>_locale with a locale-shaped tag (fr, fil, pt_BR, zh-Hans, es-419). The shape check is what keeps the skip honest: locale_helper.sl and locale_switcher.sl are code, not data, and stay linted. Skips are never silent — the summary reports (N locale files skipped), and naming a file explicitly lints it regardless, which doubles as the escape hatch. soli check is unaffected.

v1.26.2 — 2026-07-30

Fixes

  • soli fmt deleted blank lines inside an if body. Reported as “fmt removes the blank line before a return”, but the return was incidental — any blank line between two statements in an if body was dropped, while the identical body under for / while or directly in a method kept it. The formatter records a block's opening line so a body-leading comment measures its gap from the opener, but it read that line off the block's span — and an if body's span line is its last statement, where for / while use the first. That pushed the bookkeeping past the whole body, so the paragraph check saw a phantom comment above every statement and swallowed the blank. Now clamped to the first statement's line, which a block's opener can never follow.

v1.26.1 — 2026-07-30

Fixes

  • Iterating a value read inside grouped(fn() { ... }) raised cannot iterate over array. A grouped read hands back a placeholder, and the block resolves what the placeholder points at without replacing the binding — so each way of consuming a value has to unwrap it, and for-in was not one of them. The message contradicted itself because asking a placeholder for its type resolves it and reports the resolved type, so a placeholder holding an array announced “cannot iterate over array”. Fixed in both for-in implementations — Soli code and <% for post in posts %> in a view are separate code paths, which is why fixing one alone would not have helped. A .map(...)-style call on a grouped value is still not covered.

v1.26.0 — 2026-07-30

ORM

  • soli test now exercises the production coalescing path. The test server runs with --dev so the AQL query log is populated — but --dev also disables grouped(fn() { ... }) coalescing, so no spec ever ran the combined LET … RETURN […] path, and assert_query_count measured dev's un-coalesced number rather than the round-trips production makes. Interactive --dev still keeps the reads separate for a readable query log; test-runner children now coalesce like production, so a grouped action reports one query in a spec, not one per read.

Dev tools

  • The dev bar and specs now flag reads that should be coalesced — the case N+1 detection is structurally blind to. N+1 detection fingerprints by query template, so it only ever fires on a repeated one. Three unrelated reads are three distinct templates with a count of one each: invisible to it, yet exactly the shape grouped exists for. The query panel now shows an amber N READS · N ROUND-TRIPS advisory for distinct one-off reads outside any grouped block, and assert_no_ungrouped_reads(response) asserts the same in a spec (raw data on response["ungrouped_reads"]). Writes and repeated templates are excluded, as are reads already inside a grouped block — that last exclusion matters because interactive --dev does not coalesce, so without it correctly-grouped code would be reported as unfixed. Advisory, not an error: dependent reads cannot share a round-trip and nothing in the log distinguishes them, so the panel and the failure message both state that precondition.
  • The N+1 hint names the fix that applies to it. It previously read “batch with FILTER doc.field IN @ids”, pointing at hand-written AQL. It now leads with includes(...), the framework fix for a repeated association read. grouped is deliberately not suggested here — it addresses distinct one-off reads, which by construction never trip an N+1 alert; that case has its own advisory above.

v1.25.4 — 2026-07-30

Dev tools

  • soli new and every soli generate emit formatted Soli. A freshly generated app was six files away from soli fmt --check clean — so the first thing an agent did, run fmt as the generated CLAUDE.md instructs, produced a diff of files nobody had touched. The scaffold's 30 .sl templates are formatted at rest, and the single write helper every generator shares now runs .sl content through the formatter on the way out, so generators added later inherit it; content the parser rejects is written through unchanged rather than failing the generator. Verified across new + auth + oidc_provider + offline + devices + app_links + scaffold + mailer + component: zero unformatted files, no lint issues, and the app serves /, /posts/new, /login and /signup.

Fixes

  • A blank line under a file header comment was deleted. The paragraph check measures the gap from the previous statement, so with no previous statement it never ran: # Migration: create_users + blank + def up(db) lost its blank, while the identical gap two statements down kept it. Same for a comment leading a block body. The blank now survives, and # soli-lint-disable-next-line still stays attached to its target.
  • A comment that was the entire body of a catch escaped the block. With no statement to flush before it, rescue / # already exists / end printed the comment after the end, where it read as documenting whatever came next. Block bodies flush through their closing line now, and the catch keyword's line is recorded — without that, a body-leading comment gained a blank line on the second pass.
  • soli generate offline produced a controller that could not be parsed. sync_controller.sl assigned an if/else as an expression, which the parser rejects — the sync routes were dead on arrival. Hoisted to a declaration assigned in each branch.
  • The offline and devices migrations tripped 18 lint warnings in the app that generated them. Their deliberately-idempotent begin/rescue steps had empty rescue bodies, which smell/empty-catch and style/empty-block both flag — so a user following the documented soli lint step met 18 warnings they had not written. Each rescue now prints which step it skipped, which is what you want to see when running a migration anyway.

v1.25.3 — 2026-07-30

Dev tools

  • The CLAUDE.md files soli new generates tell the agent to run soli fmt, not just soli lint. fmt is now step 1 of the verification loop in the root file, in tests/, in app/controllers/ and in the /soli-verify command; app/models/, app/middleware/, app/views/ and db/migrations/ gained a “Before you're done” block with both commands scoped to that directory. fmt goes first because it rewrites layout in place: several style/* rules stop firing once it has run, so what lint reports afterwards is the part that needs judgement. Each file also states what fmt does not touch — .html.slv templates stay hand-indented, and a """ … """ query is rewritten while [[ … ]] is preserved.
  • soli fmt gives an early return a blank line after it. A guard clause and the body it guards were run together as one block of lines, which is the shape you most want to read at a glance. A return is now followed by a blank line — with two exceptions, both cases where the blank would only add noise: when the next statement is another return, so a run of guards still reads as one paragraph, and when the end follows, since there is nothing below to separate it from. This covers postfix return x if cond — in practice the only kind of return with code after it, the rest being unreachable — and matches the blank the formatter already emits after a block-form guard. See Formatting.

Fixes

  • soli fmt re-escaped raw strings, collapsing a multi-line SDBQL query onto one over-long line. A [[ … ]] literal went through the escaping path, so a 6-line migration query came back as a single 226-character "\n FOR post IN posts\n …" — semantics intact, but the query unreadable and style/line-length now failing on code the formatter itself had just written. r"…" met the same fate (r"C:\x" became "C:\\x"). Both forms are now re-emitted from their source bytes, with the enclosed text checked against the lexed value first — on any mismatch the formatter falls back to escaping rather than emit a literal that means something else. """ … """ is still rewritten, so reach for [[ … ]] when a query spans lines.
  • A freshly generated app failed the soli lint step its own CLAUDE.md documents. Two issues, both shipped in the scaffold: application_helper.sl's link_to_class was a 136-character line (style/line-length), and auth.sl compared an API key with == "" (idiom/prefer-blank). soli new now produces a lint-clean app.
  • The generated CLAUDE.md listed three generators that don't exist, 600 lines after warning against them. Its “Common commands” block still advertised soli generate controller|model|migration while the section above explained that reaching for exactly those is how an agent wastes its first five minutes in a new app. Replaced with the real commands, and db/migrations/CLAUDE.md no longer tells agents to name files with soli generate migration either.
  • soli fmt produced code that soli lint rejected — and in one case code that meant something different. Four defects, all reachable by running soli fmt then soli lint on a real app. A block unless a || b was reformatted to if !a || b — that is (!a) || b, a different program, and it changed behaviour silently: the formatter's paren rule covered <-style comparisons but not &&/||. It now parenthesises every operator that binds looser than unary. Wrapping a long &&/|| chain put the operator at the start of the next line, but a Soli statement ends at its line break, so the file no longer parsed (Unexpected token '&&'); the operator now trails. Width estimates were clamped at 60 chars per call argument and 40 per array element, so a call or array holding one long string was judged to fit and printed past the limit, tripping style/line-length. And a string literal's span excludes its closing quote, leaving every estimate one byte short per string — literals, arrays, hashes and groupings are now measured exactly from the AST, and arrays break on width alone rather than only on element count. fmt stays idempotent; formatting a 105-file app now leaves soli lint clean where it previously reported a parse error and eight length violations.

v1.25.2 — 2026-07-29

Dev tools

  • A mail inbox at /__soli/inbox — every email the app sends, with no local SMTP server. /__soli/mailers previews templates with fake data; the inbox shows what was actually sent, with the real data that was rendered into it. Each message opens with its headers, its attachments, and tabs for the HTML body (in a sandboxed iframe, so a mail's own scripts never run), the text part, and the raw RFC 5322 source — downloadable as .eml to open in a real mail client. The listing is searchable and paginated: ?q= matches subjects, any address, both bodies and attachment filenames, ?per= and ?page= page through it, and a filtered view is a plain link. New arrivals announce themselves with a badge rather than reloading the page under you. Rails needs the letter_opener gem or a separate MailCatcher process for this. See Mailer.
  • The dev bar has a tools menu, so the dev-only galleries are no longer URLs you had to know. /__soli/inbox, /__soli/mailers and /__soli/components have been reachable only by typing them; they are now one click from every page, each opening in a new tab so the page under inspection stays put. The button carries the inbox's message count, so mail sent while you were clicking around announces itself.
  • Every /__soli/* page carries a link back to the app. The galleries and the inbox were one-way trips — you arrived by URL or from the dev bar, and the only way out was the browser's back button. The standalone preview pages get it too, but only when opened directly: the catalogs mark their iframe srcs, so the link doesn't repeat inside every gallery card.
  • A mailer with no SMTP host no longer fails under --dev. A dev box rarely runs a mail server, and a signup flow that dies on deliver_now is a poor way to learn that. Under --dev an unconfigured host now captures the message into the inbox and lets the request carry on, the way letter_opener does. Each message is tagged sent (an SMTP server accepted it), captured (never left the process), or failed — and a failure is captured too, with its error, since a rejected recipient or a refused connection is exactly what you opened the inbox to look at. Outside --dev nothing is captured, the routes don't exist, and an unconfigured host is still a hard error.

Removed

  • The dev database browser at /__soli/db is gone. The collection list, the paginated row tables (/__soli/db/<collection>), the JSON document view and the read-only SDBQL query box have all been removed, along with their routes. Use the app-aware TUI REPL (soli in an app directory loads your models and DB connection) or SolidB's own tooling to inspect data. Dev-only to begin with, so nothing changes in production.

Benchmarks

  • The published Laravel Octane memory row measured the supervisor, not the server — wrong by ~5×, in the flattering direction. The probe matched the supervising PHP CLI, while the actual server process was skipped because its smaps_rollup is root-owned and unreadable, contributing a silent zero. Both containerised rows are corrected from cgroup usage: Laravel php-fpm 84 → 104 MB idle, Laravel Octane 43 → 200 MB idle. That inverts what the page claimed — it said Octane used less memory than php-fpm; the truth is the intuitive one, Octane roughly doubles both throughput and memory. The five native stacks were audited and have zero unreadable processes, so their figures stand, and the harness now warns loudly when a sum would skip a process rather than undercounting in silence. See Benchmarks.

Fixes

  • Email sent from a --dev server carried the dev bar's instrumentation into recipients' inboxes. The hover overlay wraps each rendered template in <!--solidev:view:start …--> comments, and mailer bodies go through the same renderer — so every HTML and text part sent from a development server shipped with those markers embedded, visible as stray text in any client showing the plain-text alternative. Mailer renders now suppress the markers; the dev bar's per-template timings are unaffected.

v1.25.1 — 2026-07-28

ORM

  • Symbols as field names. Every Model static and chained QueryBuilder method now accepts Ruby-style symbols wherever a field name is expected — Post.pluck(:id, :title, :views).all, User.order(:created_at, :desc), User.sum(:balance), find_by(:email, ...), increment(:views). The language always had symbol literals; the argument matchers just refused them. Strings keep working — this is sugar, and a Soli controller line now reads almost byte-for-byte like its Rails equivalent.

Benchmarks

  • The framework comparison now runs six stacks over seven workloads, and the apps are in the repository. Benchmarks gained Laravel (php-fpm + nginx, Eloquent + Blade), Django (gunicorn, Django ORM + templates) and AdonisJS 6 (Node cluster, Lucid + Edge) beside Soli, Rails and Express, plus three write rows — one create, update and delete per request against an isolated 800,000-row table reset before every cell. All six apps and the harness now live in bench/frameworks/; previously they sat in a scratchpad, so the published numbers were not reproducible by anyone.
  • AdonisJS lands third on every row, and costs 2,815 MB idle to do it. A full TypeScript framework with ORM and template engine sits where you would expect — ahead of Rails, Django and Laravel, behind Soli and Express — but the memory row is the one worth reading: roughly 197 MB per worker against Express's 65, because each of 16 cluster workers carries its own copy. That is 3.4× Django and 33× Laravel on php-fpm, which is lean precisely because it holds nothing between requests. Soli's 16 workers are threads in one process: 50 MB idle.
  • Every figure was re-measured in a single sweep, not spliced. Adding a stack re-runs the whole table rather than dropping a new row beside numbers taken weeks earlier — the standard applied when Laravel was added and again for AdonisJS. All stacks now read 5–15% below the five-stack run because the box also hosts Octane, Adonis and Redis; internally consistent is what a comparison needs, and absolute numbers from different box states are not comparable to each other.
  • Every table carries the code that produced it. Each row now has a tab strip — Result, then the handler in each stack — so the claim that six frameworks are doing the same work can be checked rather than taken on faith.
  • WebSocket rows: echo and fan-out, for Soli, Express and Rails ActionCable. Express leads the round trip (411,340 msg/s against Soli's 241,790); on fan-out all three deliver to the whole room at the same rate (45,264 / 45,545 / 44,217 per second). The row worth reading is what it costs to get there: Soli's workers are threads in one process, so a broadcast reaches every connection with nothing configured, while clustered Node and ActionCable each need Redis — and clustered Node without it silently delivers to 6% of the room rather than erroring.
  • Fairness fixes that changed the results, all now stated on the page. Express was moved off the raw pg driver onto Sequelize, since measuring one stack's hand-written SQL against four ORMs flattered it by 34%. PostgreSQL runs synchronous_commit=off for the write rows, because SoliDB acks before fsync and comparing buffered writes to durable ones is not a comparison. Laravel and Django needed persistent connections or they were measuring ~8ms of connection setup per request. Laravel Octane is published as a labelled reference row, not as “Laravel”: it roughly doubles every result, and presenting the faster runtime under the framework's plain name would flatter it the same way the raw driver flattered Express.
  • One Soli row was measuring a bug rather than Soli. render_json(Post.pluck(...).all) passes the builder inline as a call argument, which evaluates it twice and sends the query twice — confirmed by query log, and by SoliDB's CPU halving when the builder is bound to a local first. Binding it took the database read from 22,769 to 36,944 req/s. The interpreter bug is fixed — see Fixes below.

Fixes

  • render_json(expr) evaluated its argument twice, so a query builder passed inline hit the database twice per request — the idiomatic one-line JSON action is now 66% faster. render_json carries an interceptor that implements the as_json override: it evaluated the first argument to check whether it was an instance whose class defines as_json, and when it was not — every hash, array and query builder — it threw the value away and let normal dispatch evaluate the same expression a second time. Harmless for a literal; for render_json(Post.pluck(:id, :title, :views).all), the shape the documentation recommends, it meant two identical round-trips to the database on every request, and nothing in the output revealed it. Measured on a 50-row JSON route at 16 workers: 22,527 → 37,425 req/s, p99 10.45 → 6.69 ms, with SoliDB's CPU per request halving. Binding the builder to a local first was the workaround; it is no longer needed. An end-to-end test now counts how many times a handler's argument is evaluated, so the regression cannot return quietly.

v1.25.0 — 2026-07-27

Language

  • Twelve field-keyed array methods that beat Ruby: sum_by, avg/avg_by, group_by, index_by, count_by, tally, filter_by, find_by, uniq_by, max_by and min_by. Each names the field as data rather than taking a callback, so the entire traversal stays in Rust and never re-enters the interpreter. That is the whole performance story: rows.sum_by("amount") and rows.reduce(fn(a, r) { return a + r["amount"] }, 0) compute the same total, but the closure form calls back into Soli once per element and measures ~235× slower on 20,000 rows. Against Ruby 4.0.6 — credited its best of interpreter, YJIT and ZJIT, and whose equivalents all take a block — Soli wins 11 of 11, geometric mean 2.5× faster. See Benchmarks and Arrays.
  • Semantics chosen to be unsurprising rather than clever. sum_by keeps integers integral, so money held as cents cannot silently become a float — it promotes only once a float is seen. avg is always a Float, because an average is a ratio and integer division would report [2, 3].avg() as 2; averaging nothing gives null, never a 0 indistinguishable from a real zero mean. group_by preserves first-seen key order and within-group order; index_by and uniq_by follow Rails and Ruby respectively on duplicates; max_by/min_by return the record and skip records missing the field rather than letting a null win. A record missing the grouping field lands under null, so counts still total the input length.
  • max and min returned the first element for arrays of strings. ["a", "b", "c"].max() answered "a". The production engine could compare every combination of numbers and had no case for strings at all, so a string never displaced the running candidate — while the engine running your tests answered correctly.
  • reduce without a starting value now works in production. [1, 2, 3].reduce(fn(a, b) a + b), where the first element seeds the accumulator, returned 6 in the engine that runs your tests and raised a wrong-arity error in the one that serves requests. Both accept it now. Passing something that is not a function to a method expecting one also names the method: all? expects a function argument, rather than a bare "cannot call non-function value" that told you neither which call was wrong nor what it wanted.
  • A padding width taken from user input could kill the server process. "x".ljust(9223372036854775807) asked the allocator for nine exabytes and aborted — in both engines, and reachable from any request that runs a number through to_i() into ljust, rjust, center, lpad or rpad. All five now refuse a width beyond 1,048,576 characters with an ordinary error. truncate is deliberately not capped, since its argument shortens a string rather than building one.
  • break and next compile everywhere they can appear. Both used to send the handler to the slower engine when written inside a try or inside a lambda. Leaving a try now runs its finally on the way out, innermost first; inside a lambda they stop the callback without touching the loop running outside it, exactly as before.
  • Every match pattern Soli can parse now compiles. The last one was the bare type test v: Type. Across this cycle, wildcard, literal, variable, typed, array (with and without ...rest), hash (likewise), enum variants with payloads, and arbitrarily nested combinations all moved from the slower engine to the fast one.
  • Hash rest patterns compile. match v { {name: n, ...rest} => … } binds the leftover keys on the fast engine. Every pattern shape the parser accepts now compiles except Type { field } destructuring.
  • Nested match patterns compile. match data { {user: {name: n}} => … }, [1, x] and [{k: v}] all run on the fast engine now. With this, no pattern shape in the standard examples or test suite falls back — only {a: x, ...rest} and Type { field } destructuring remain.
  • Array rest patterns compile. match v { [first, ...rest] => … } no longer sends the handler to the slower engine. Hash rest and nested sub-patterns still use the other engine.
  • Enum-variant patterns compile. match s { Status.Active => …, Status.Pending(r) => … } — the shape most enum code is written in — no longer sends the handler to the slower engine, payload binding included.
  • Hash patterns compile. match v { {name: n, age: a} => … } no longer sends the handler to the slower engine. A missing key falls through and extra keys are ignored, as before. {name: n, ...rest} still uses the other engine.
  • Fixed-length array patterns compile. match v { [a, b] => … } no longer sends the handler to the slower engine. [a, ...rest] and nested sub-patterns still use the other engine.
  • A match that matches nothing now raises instead of quietly returning null. A match falling through every arm raised in the engine that runs your tests and evaluated to null in the engine that serves requests — so a missing case failed in testing and produced a null in production. Both raise now. Typed patterns (Int: n, String: s) also compile, guards included.
  • Binding match patterns compile. match n { 0 => "zero", x if x < 0 => "negative", x => "positive" } — the ordinary shape of a match — used to send the whole handler to the slower engine, since only wildcard and literal arms compiled. Guards see the bound value, so x if x < 0 works. A binding used mid-expression (as a call argument) still uses the other engine.
  • SOLI_LOG=http no longer prints credentials in outgoing URLs. The channel logs the full URL of every outgoing call, and query strings routinely carry ?api_key=…. Only the credential-looking values are replaced, so the endpoint stays readable. With this, every production log surface — error environment, request snapshot, query binds and outgoing URLs — shares one definition of what a secret is.
  • SOLI_LOG=query no longer prints credentials. That channel logs each query with its bind variables — and bind variables are where a query's values live, so a login wrote the submitted password straight into the production log. Bind values named like a credential now show [REDACTED]; everything else still prints, so the channel stays useful.
  • Local variables no longer leak secrets into the production error log. When a request fails, the log records the failing handler's local variables by value — so let api_key = "ak_live_…" was written to disk verbatim. Request params were already redacted, so the same secret could be hidden in one part of the log and printed in full a few lines below. Both now use one rule, and a local named like a credential is logged as [REDACTED].
  • Re-declaring a local no longer sends the handler to the slower engine. let x = 1 then let x = 2 in the same scope has always run fine, but the compiler rejected it, so a handler containing one ran interpreted. It compiles as the assignment it is. Cases involving const still use the other engine on purpose.
  • Safe navigation (&.) compiles natively. Any handler using user&.address&.city — the idiomatic way to walk a chain that might be absent — used to run on the slower engine. Both the property read and the method call compile now, and a null receiver still skips the arguments entirely, so nil&.foo(bar()) never runs bar(). A handler using &. no longer registers as a demotion.
  • next compiles natively. Like break, it was refused, so any loop that skipped an element ran on the slower engine. Both spellings (next and next()) now compile for for, while and range loops — a next-heavy loop is 2.9× faster. A program that declares its own next still gets an ordinary variable.
  • break compiles natively. It was refused outright, so any handler containing a break — the ordinary way to stop scanning once you have found what you wanted — ran entirely on the slower engine. A break-heavy loop is now 2.8× faster, and such a handler no longer registers as a demotion. break inside a try is still handled by the other engine — that one shape only.
  • finally is compiled properly now, so handlers using it run at full speed. The previous release refused to compile try/finally and fell back to the slower engine — correct, but a whole-handler penalty. The block is now emitted on every way out of a try, including a throw raised inside a catch clause. A finally-heavy loop is 5.3× faster than the engine it used to fall back to, and such a handler no longer shows up as a demotion at all.
  • An uncaught error could return 200 null instead of failing, and a top-level throw did nothing at all. A return from inside a try left the exception handler registered after its function had returned, so a later error matched a handler that no longer existed and vanished — a request that raised answered HTTP 200 with a null body rather than erroring. It now returns 500. Separately, a throw at the top level of a script was discarded by the interpreter and execution simply continued to the next statement; it now stops the program, as it always did under the server.
  • order, all and includes on a loaded array worked in tests and raised in production. has_many accessors return a plain array, so org.contacts.order("name").all() lands on one. The engine that runs your tests accepted it; the engine that serves requests answered Cannot access property 'order' on Array. All three now work in both, sharing one implementation so they cannot order differently, and soli check accepts the chain instead of rejecting it. Ordering by a field missing from some rows is now deterministic — absent sorts first — rather than leaving those rows wherever they landed.
  • String#chr was advertised everywhere but implemented nowhere. Tab completion, the type checker and the member whitelist all listed it, so soli check accepted "abc".chr() while the runtime rejected it. It now works in both engines with Ruby's semantics — the first character, never half a multi-byte one, and "" for an empty string. Tab completion also offered none? and one? on integers, which are array predicates; those entries are gone.
  • The optimizer corrupted every try block containing an optimizable instruction. When the compiled engine fuses instructions for speed it rewrites the jump offsets that follow — but a try carries two targets of its own that were never remapped, so the catch handler landed one instruction into its own body. Depending on what was fused, the catch block ran with garbage local variables, the catch was skipped entirely so the error escaped, or the engine crashed. Six of ten ordinary shapes were affected — h.n, i = i + 1, a * 2, and any #{...} containing a method call. A try with no optimizable instruction was unaffected, which is how it went unnoticed.
  • A throw from inside map/filter/each/reduce lost its value, and sort_by swallowed it entirely. These methods run the callback from Rust, and a thrown value was destroyed crossing back — so rows.map(fn(r) { throw {"code": 422} }) could not be caught as a hash. sort_by was worse: it gave the element a null key and returned the list unsorted with the exception gone. Sixteen callback methods now preserve the thrown value, including a class instance, so catch e { e.message } works through a callback.
  • debug() did nothing in compiled mode, and internal routing markers leaked into caught errors. debug() signals through a sentinel value that only the interpreter recognised, so under the engine that serves requests the breakpoint never fired — the same shape as the next bug, and these two are the only such sentinels, so the class is closed. A program with its own let debug = 42 is unaffected. Separately, Model.find and forbidden() tag their errors internally so the request layer can turn them into a 404 or 403; catching one used to bind that raw tag, so rendering it printed __Forbidden__:nope on the page. A caught error now reads as its message.
  • finally did not run when a try was left early, and could swallow an exception outright. In the engine that serves requests, finally ran only when the block reached its end — so try { return x } finally { conn.close() } leaked the connection in production while releasing it under soli test, and a throw with no catch clause was discarded, making the error vanish. finally now runs on every way out of a try: normal completion, return from the try or from a catch, a handled throw, and an unhandled one. A return or throw inside the finally itself now takes over from whatever was in progress, as Ruby's ensure does.
  • A thrown value lost its type when it crossed a function call. throw {"code": 404} caught in the same function gave a hash; caught one call up it gave the text of one, so e["code"] failed — structured errors only worked when thrown and caught in the same function body. The value now survives any call depth, as a hash, array, int or instance. A throw crossing a call is also 31× faster, because the error path was serializing every local for the dev error page on every throw.
  • next was silently ignored inside a loop. if i == 2 { next } skipped correctly in the engine that runs your tests and did nothing in the engine that serves requests, so a filtered loop quietly processed the rows it was told to skip. It is now refused by the compiler the same way break already is, which routes the handler to the engine that implements it — a loop using next now gives the right answer.
  • Returning from inside a loop corrupted the loop that called you. A function that does return from within a for loop left its iterator behind, and the caller's loop then consumed the callee's remaining items — so an outer loop over three records could run five times and see the inner function's values instead of its own. It needed nothing exotic: any function that returns early from a loop, called from inside another loop. Fixed in the production engine, which is where it happened; the interpreter was always correct.
  • Summing an array of floats returned zero, and substring crashed on non-ASCII text. The production engine kept a fast path for sum, min and max that handled whole numbers and silently skipped everything else, so [1.5, 2.5].sum() was 0 and [1.5].min() was null — while the engine running your tests answered correctly. And "é".substring(0, 1) crashed the process, because the slice was taken in bytes and cut a character in half; it now counts characters. fetch also now raises on a missing key, as in Ruby, where get returns null.
  • sort_by returned unsorted data instead of reporting a failing sort key. A key expression that raised — a typo'd method, a type error — was swallowed and that element got a null key, so every element compared equal and the list came back in its original order with no error. It now raises, as the production engine always did. Fixed alongside it: hash.size() raised in one engine and worked in the other, arr.push(x) returned the array in one and null in the other (it now returns the array, so a.push(1).push(2) chains), arr.get(out_of_range) now answers null like every sibling, and comparison errors named their operands backwards — [1] > 1 reported "int and array".
  • Passing an argument to a method that takes none is now an error, not a shrug. "abc".nil?("junk") returned false in the production engine and raised in the one tests use. class, nil?, blank?, present?, inspect and to_s now reject extra arguments in both engines. Real calls are untouched — [1,2,3].join(",") still joins and "ff".to_i(16) still parses.
  • Eight more methods were unreachable through soli check. uppercase/lowercase were unknown to the checker entirely, delete_prefix/delete_suffix were declared as taking no argument despite needing the affix to strip, and casecmp?, ascii_only?, assoc and rassoc were missing outright. [1, 2].to_s() also ran in the production engine and raised in the one tests use; it is now an alias of to_string in both.
  • Optional arguments the type checker did not know about. config.get("port", 8080) and fetch(key, default) return the default when a key is missing, and center, ljust, rjust, lpad/rpad and truncate all take an optional pad or omission string. Every one of them ran correctly and failed soli check, because the checker declared fewer parameters than the implementation accepts.
  • soli check rejected five working String methods. count, index_of, scan, partition and rpartition were declared as taking no arguments. A zero-argument member is auto-invoked, so s.count("a") resolved to an Int and the checker reported Cannot call non-function type 'Int' — rejecting a call that runs fine, with a message pointing nowhere near the cause.
  • [].pop() now returns null instead of raising. Every sibling already did on an empty collection — shift, first, last, min, max — as does Ruby, leaving pop the only one that could blow up. It raised in the interpreter that soli test uses and returned null in the engine soli serve uses, so the same line failed in tests and passed in production.
  • inspect keeps the quotes on nested strings, and hash.to_s() works everywhere. The production engine rendered [1, "a"] as [1, a] — using the plain display form for what is meant to be an unambiguous debug rendering, and inconsistently, since a bare "s".inspect() did quote. Both engines now share one renderer. to_s on a hash previously worked in one engine and raised in the other; it is now an alias for to_string in both — which surfaced a dormant bug that rendered a hash with square brackets.
  • A typo in a hash method name now raises instead of silently returning null. record.lenght() resolved to a missing key, and the rule that lets n.abs() work handed that null straight back. Because soli test runs the tree-walking interpreter and soli serve runs the bytecode VM — which already raised — the typo passed the suite and failed in production. Both engines now agree. Anything the member access can genuinely resolve still works: shift, methods added by define_method, and universal members like nil? and class.
  • A function stored in a hash can now be called under the production engine. handlers.on_create(record) — a dispatch table — worked in the tree-walking interpreter but raised Cannot access property in the bytecode VM, which never checked whether the key held a callable. Because the VM is what soli serve runs, the pattern passed locally and failed once deployed. It now works in both engines for zero, one and many arguments; built-in method names still take precedence over entries of the same name, as before. Five cases now guard it in the engine-parity test.
  • soli --vm -e ignored --vm. It always ran the tree-walking interpreter, so the quickest way to check how the production engine behaves was the one way that could not. That is also why the hash dispatch bug above went unnoticed for so long.
  • Dates before 1970 with sub-second precision no longer raise Invalid timestamp. Every DateTime accessor split the stored epoch nanoseconds by hand, and the sub-second remainder is negative before 1970 — cast to an unsigned value it became ~4.29 billion nanoseconds, which is not a valid offset, so the accessor raised. DateTime.parse("1969-07-20T20:17:00.500Z") parsed fine and then .year() failed. Whole-second instants have a zero remainder and were unaffected, which is why it went unnoticed. All 21 conversion sites now use an infallible conversion that handles negative instants correctly; speed is unchanged.
  • pluck and pick no longer return null for every ORM row. Their field accessor understood hashes and array rows but not instances — which is what Model.all() and where(...) return — so User.all().pluck("email") came back as a list of nulls instead of raising. One shared accessor now serves pluck, pick and all twelve field-keyed methods across both engines, so every one of them reads a record identically.
  • Passing a closure where a field name belongs is now an error, not a wrong answer. These methods read a field, so a block argument previously matched nothing and returned an empty or zero result silently. They now raise and name the block-taking alternative — max_by(fn(x) ...) points you at sort_by(fn(x) ...).last().

Language

  • Block-form unless ... end. unless only existed as a postfix modifier, so a multi-line guard — the shape Soli's own guidance recommends — did not parse. It now does, and takes an else branch. elsif after unless is deliberately not accepted: "unless A, else if B" reads as a puzzle rather than a guard, which is why Ruby rejects it too.

Operations

  • A panic in one worker no longer takes down the server. [profile.release] set panic = "abort", under which catch_unwind never catches — so the two worker-restart supervisors that were written to contain a panic were dead code in every released binary, and a single panic aborted the whole process along with every other worker. Release builds now unwind, a per-request guard turns a panicking handler into an immediate 500 (previously the caller waited 40s for a 504 from a worker that was already gone), and a new soli_handler_panics_total counter surfaces it on /_metrics. A compile_error! guard now fails the build if panic = "abort" ever returns, so the regression cannot be silent again.
  • Graceful shutdown — rolling deploys no longer truncate requests. SIGTERM/SIGINT previously called exit(0) immediately, cutting off whatever was mid-flight. The server now drains: readiness starts failing so the load balancer stops routing, new requests get a clean 503 … Connection: close, and requests already in flight run to completion before the process exits — bounded by SOLI_SHUTDOWN_GRACE_SECS (default 25s, just under Kubernetes' 30s default). A second signal exits immediately.
  • Health and readiness endpoints. GET /_health reports liveness — 200 for as long as the process serves, including mid-drain, so an orchestrator never restarts a container that is already exiting cleanly. GET /_ready reports readiness — 503 starting while workers boot, 200 ready when serving, 503 draining during shutdown. Both are plain text and need no configuration. See Configuration.

Performance

  • Database queries run on each worker's own reactor — +12% throughput at 16 workers, +23% at 32, and the connection churn toward SoliDB is gone. Every query used to hand its I/O to the server's shared runtime, which funnelled all readiness through a single driver thread and then had to wake the waiting worker for each completion — a per-query cost (~190µs at 16 workers) that grew with the worker count, which is why adding workers stopped helping. Separately the shared pool kept only 8 idle connections, so under 200 concurrent requests the rest were discarded after every query: ~1,300 TCP connects/sec and 12,900 TIME-WAIT sockets in a ten-second window. Each worker now keeps one hot connection and polls its own readiness. Measured on an idle 16-core box against a loopback SoliDB, full request through a single-document find: 25,905 → 28,955 req/s at 16 workers, 31,679 at 32 (the old path was flat-to-negative with worker count); a 50-row scan route 12,452 → 14,067; TIME-WAIT growth over a 20s run +4,086 → 0; p50 7.68 → 6.86 ms. SOLI_DB_SHARED_REACTOR=1 restores the previous behaviour. No code change needed — existing apps get this on upgrade.
  • --workers 2 was serving on one thread, so it performed exactly like --workers 1 — now +85%. Soli reserves a worker for realtime (WebSocket/LiveView) events so a burst of them can’t starve HTTP, but it reserved one at every pool size — and on a two-worker pool that is half your capacity. Both settings ended up with a single HTTP worker and measured identically: a database-backed route sat at 11.2k req/s either way, where two HTTP workers reach 20.7k. It was silent unless you read the Worker pool: line at startup, and it hit hardest on exactly the small pools the configuration guide recommends for saving memory. The reservation now applies by default only from 4 workers up, where it costs 25% rather than 50%; below that every worker drains both channels — which is what a single-worker pool always did — so realtime keeps working and simply shares the pool. Setting SOLI_WS_WORKERS explicitly still forces the split at any size, or 0 disables it; at least one HTTP worker always survives. Measured on an idle 16-core box against a loopback SoliDB: workers=2 11,191 → 20,651 req/s, workers=3 20,343 → 26,014, workers=4 unchanged at the threshold. No code change needed — existing apps get this on upgrade.
  • Worth knowing when you size the pool: a worker blocks for the whole of each database round-trip, so a DB-backed route tops out near workers × (1 / query latency) — about 11k req/s per worker against a loopback SoliDB. Routes that never touch the database are unaffected: a single worker serves over 140k req/s. If a DB-backed benchmark looks low, the worker count is the first thing to check.
  • A DateTime is now a native value, not an allocated object — the category is 1.22× faster. It used to be an object, so every operation paid a reference-counted allocation plus a hash-map insert, and every accessor paid a string-keyed lookup to read the timestamp back out. Operations that return a DateTime gained most: from_unix −26.1%, subtract_days −25.2%, parse −24.9%, now −24.3%, end_of_month −23.3%, add_hours −22.2%. Against Ruby 4.0.6 the DateTime category improves from 2.43× to 1.88×. Verified by diffing every DateTime method across 16 timezones and 12 DST-straddling timestamps — byte-identical in all 16, with no interpreter-vs-VM disagreements.
  • DateTime local-time conversion is cached: end_of_month −48.5%, year −12.5%, category geometric mean −9.3%. chrono's Local re-resolves the system timezone on every call — every accessor paid it once and the boundary methods twice. Resolving the zone once per process cuts a conversion from 47.2 ns to 20.2 ns, and the from_local_datetime path from 234.7 ns to 21.6 ns, which is why the month and year boundaries gained most. $TZ is still honoured first, exactly as before: resolving through the system zone alone would silently ignore the ENV TZ=UTC most containers set, so a $TZ holding a POSIX spec rather than an IANA name keeps the old path and stays correct. Verified by diffing every DateTime method against the previous binary across 16 timezones and 12 DST-straddling timestamps.
  • Creating an object is 16–31% faster. Construction cost 89 ns fixed plus 84 ns per field, and the per-field part had three separate causes. Instance fields were keyed by a heap-allocated string, so every field name was an allocation — names up to 15 bytes are now stored inline. The fast path for writing a field only handled updates, so a first write fell through to a slower path that allocated the name twice — and every field of every constructor is a first write. And the field map was built at default capacity, so a constructor rehashed partway through filling it. Measured: 1 field −16%, 4 fields −29%, 8 fields −31%; per-field cost 92 → 58 ns. Duration.of_days and DateTime.from_unix each gain 8% from the same change. Model instances, controller instances and every user class go through this path.
  • Reading a field off an object is 65% faster; class-heavy code runs 21–25% faster. Two fused instructions already existed in the bytecode VM — declared, implemented, disassembled — but the optimiser never emitted them, so obj.field and arr[i] each paid an extra dispatch every time. Measuring first showed why that mattered: a bare field read cost +27 ns over reading a local, while an entire native String.len() call cost only +8.5 ns — and the cost did not change with the number of fields or the length of the field name, so it was the dispatch, not the lookup. A field read now costs +8 ns. Measured A/B: 200,000 field reads −25%, a method body with four this. reads −21%, hash[key] −17%. Every model attribute read and every this. in a controller goes through this path, so applications get it on upgrade with no code change.
  • join on numbers −65%, avg −80%. Rendering a value measured its own width by building a throwaway string and reading its length, then built a second string to actually render it — so joining a 1000-element array of numbers made 2000 allocations that were immediately discarded. Width is now counted arithmetically and rendering writes into a stack buffer. Output is byte-identical, pinned by tests at every digit boundary and at the 64-bit limits; floats deliberately keep the old path, because the faster formatter renders 2.0 where Soli renders 2. avg separately cloned every element where the neighbouring sum read by reference. Measured on 1000 elements: join 5.95 → 2.09 ms, avg 0.785 → 0.157 ms. Every join, to_string and #{} interpolation of a number benefits.
  • reverse is 8× faster, swapcase −70%, chars −25%. All three decoded and re-encoded UTF-8 where the answer needed neither. reverse and swapcase now take a byte-wise path when the input is ASCII — reversing ASCII bytes is reversing ASCII characters. The Unicode path stays and is still the only correct one for the rest: "Straße".swapcase() is "sTRASSE", where the mapping changes length. chars was allocating a whole string per character, then storing it in a value that holds one character inline — an allocation, a copy and a free per character, all thrown away. reverse goes from 2.80× slower than Ruby to 0.49× — twice as fast, and the String category is now 12 of 13 against Ruby at its best. See Benchmarks.
  • flatten −33%, intersection −14%, difference −9%. Three allocation and hashing fixes with no change in behaviour. All three grew their result from an empty vector, so a 20,000-element result paid ~14 reallocations and the doubling copy behind them; each now sizes the result up front. intersection and difference also kept two hash sets and hashed every element twice — once for “is it in b”, once for “have I emitted it” — where a single set answers both: seed it with b, then remove on a hit for intersection, or insert for difference. Measured on 20,000 elements: flatten 0.171 → 0.115 ms, intersection 1.164 → 1.001 ms, difference 1.593 → 1.450 ms. The Array category moves 1.24× → 1.13× against Ruby, and the suite overall 1.14× → 1.10×. See Benchmarks.
  • uniq, union, intersection and difference were quadratic — now linear. Each tested membership by scanning its own output, making them O(n·k) in the number of distinct elements. On an all-unique array uniq() cost 1 ms at n=1000 but 295 ms at n=16000, quadrupling on every doubling. They now use a hash set: 295 ms → 0.90 ms at n=16000 (327×), intersection 35.2 ms → 0.43 ms at n=4000. Every array and hash method now scales linearly. Semantics are unchanged, including the subtle cases: [1, 1.0].uniq() is still one element (Soli compares 1 == 1.0), -0.0 and 0.0 still collapse, every NaN still survives, and arrays/hashes still dedup structurally. Hash methods were measured too and were already fine — get and has_key are flat in collection size.
  • ~11% less CPU per request on pages that can't be response-cached. Rendering began by hashing the entire render data — every byte of every string, so on a list page proportional to the whole result set — to build a response-cache key. But the flags that decide cacheability are set during the render: csrf_meta_tag() marks the response dirty, and the layout renders after the cache lookup but before the store. Such a page looked cacheable on entry, paid the full hash, missed, rendered, and was refused storage — every request, forever. Since the default soli new layout calls csrf_meta_tag(), that was the normal path for real apps. Soli now remembers which (template, layout) pairs were refused and skips the hash for them. Measured on a 300-row list page: 372 µs → 330 µs CPU per request and 3076 → 3522 req/s. Pages that do cache are unaffected. No code change needed — existing apps get this on upgrade.

Fixes

  • Multi-field Model.pluck(...) handed back half-hydrated model instances. A projection went through the same hydration as a full document, so Post.pluck("title", "slug") returned Post instances carrying only those two fields — they read fine, but they looked like models, and save on one would have written a partial document with no key. Projected rows are now plain hashes on every path that produces them, and the output shape is unchanged. Worth knowing while you are there: prefer Model.pluck(...).all over Model.all.pluck(...), because the former projects in the database — a 50-row route went from 13.3k to 21.2k req/s, with the wire transfer dropping from ~15 KB of full documents to 2.3 KB of named fields per request.
  • A DateTime serialised to JSON as {}. Every timestamp in an API response, and every DateTime written through a model, came out an empty object. A DateTime was an object whose only field was a private internal one, and the serialiser deliberately drops private framework fields — leaving nothing to emit. It now serialises as an RFC 3339 string, matching to_iso(). For the same reason str(dt) printed <DateTime _ts: 1794744000000000000>, leaking the internal field; it now prints the same local wall clock to_string() returns. Both are behaviour changes, but neither previous output was usable.
  • The DateTime month and year boundary methods crashed on daylight-saving dates. beginning_of_month, end_of_month, beginning_of_year and end_of_year built a local wall-clock time and then unwrapped it, which panics when that time either does not exist (the hour clocks skip forward) or is ambiguous (the hour they repeat). Under TZ=America/Havana, DateTime.parse("2026-11-15 12:00:00").beginning_of_month() panicked — a 500 on that request. Scanning all 597 timezones over 2015–2035 found 17 such dates across Africa/Cairo, America/Asuncion, America/Havana, Asia/Amman, Asia/Almaty, Cuba and Egypt, several of them still in the future. Separately, beginning_of_hour and its neighbours returned a Failed to compute… error on the fall-back hour in Europe/Paris, Europe/London and Asia/Beirut. Both resolve to a value now: an ambiguous time takes the earliest of the two instants — the first time the wall clock reads it, which is what “beginning of” means — and a nonexistent one moves forward to the moment the gap closes. The conversion is total, so these methods have no failure case left.

v1.24.1 — 2026-07-24

Native & mobile

  • Generate shells, devices, deep-link proofs, and an offline outbox. soli generate devices scaffolds a Device model, POST /devices, prune helpers, and deliver_to_user for Push.deliver (migration uses begin/rescue if the collection already exists). soli generate client android|ios|linux|windows emits WebView shells (Android optional --fcm Gradle path; iOS posts APNs tokens). soli generate app_links writes well-known host files; soli generate offline adds /sync/push, /sync/pull, and soli_outbox.js. Desktop artifacts accept --open / scheme URLs after the launch token; camera scan= auto-loads a WebKit barcode decoder. Background location and IAP helpers reject unless a shell opts in. See Native clients, Devices, and Platform limits.
  • Motion sensors — gyroscope, accelerometer, orientation. soli.sensors.gyroscope(cb) / accelerometer(cb) / orientation(cb) ride the web DeviceMotion / DeviceOrientation events, which fire in mobile browsers and both WebView shells — a thin client helper, not a native bridge. Each returns a Promise<{ stop() }> with a normalized reading (gyro rad/s, accel m/s² gravity-free, orientation degrees). It handles what hand-written code forgets: the iOS 13+ permission gesture, stopping the listener on instant-nav (soli:visit), one shared listener per event, and per-subscription throttling. Opt in by referencing soli.sensors inline or calling motion_sensors() for external JS; a page that does neither downloads nothing. See Motion Sensors.

PDF

  • SIREN / SIRET on both parties of a Factur-X invoice. The typed invoice carried a VAT id and nothing else, so the generated CII had no SpecifiedLegalOrganization at all — valid EN 16931 (BR-CO-26 accepts a VAT number alone) but not a French invoice, whose statutory mentions include both parties' SIREN and whose routing keys off the SIRET. seller.legal_id / buyer.legal_id now fill BT-30 / BT-47, and the ISO 6523 scheme is inferred from the digit count — 9 digits is a SIREN (schemeID="0002"), 14 a SIRET (0009). Whitespace is stripped, so the readable "512 345 679 00017" travels as the 14 bare digits a directory expects; legal_id_scheme overrides for any other ICD code, and a non-numeric registration with no explicit scheme (a Dutch "KvK 34567890") stays a bare <ram:ID> rather than being tagged with a wrong French one. Both identifiers also reach the template as company.registration / customer.registration, so the PDF can print what the XML carries — the invoice_compliant and credit_note samples are now French invoices demonstrating both schemes. See PDF & Factur-X.

Dev tools

  • Signed over-the-air auto-update for standalone & desktop artifacts. A built artifact was a frozen binary — a fix meant asking every user to re-download it. Built with --update-url <base> and --update-key <p256-pubkey>, an artifact now understands --check-update / --update: it fetches <base>/<channel>/latest.json, verifies its P-256 signature against the embedded key, downloads the artifact for its own platform, verifies the sha256, and atomically self-replaces — staged then renamed, so a failed or tampered download never touches the installed binary. An auto-updater is an RCE channel, so the manifest must be signed (unsigned is accepted only when no key was embedded, with a loud warning); downgrades are refused. soli update-keygen makes a keypair, soli sign-update signs a manifest, and building drops a .update.json stub to merge in. A new Updater builtin (version/check/apply) drives the flow from Soli for an in-app “update available” prompt. Mobile shells are excluded — they update on deploy. See Auto-Update (OTA).

Auth & security

  • Dependency bump: ammonia 4.1.4 (RUSTSEC-2026-0213). The advisory covers an XSS in ammonia 4.1.3, where script could be smuggled through SVG animate / set animation tags. Soli’s sanitize_html was not affected — it replaces ammonia’s default tag set with an explicit 22-tag allowlist containing no SVG elements, so those tags were already stripped — but the advisory failed cargo audit, which gates every push and PR. Nothing changes in how sanitize_html behaves.

v1.24.0 — 2026-07-23

Dev tools

  • A native iOS shell. clients/ios (UIKit + WKWebView), a WebView onto the remote deployment like the Android one, carrying the full bridge: notifications + APNs, camera, geolocation, haptics, share, an arbitrary icon badge and Core NFC (both beyond the macOS shell), biometrics, keep-awake, print, clipboard, and deep links. Ships as an Xcode-ready project; custom-scheme deep links and the device capabilities build free, while push / Universal Links / NFC need a paid account. The capability table's iOS column now reflects the shell.
  • Badge counts on every shell, and through the push. Fcm.send maps a payload badge to the Android notification's notification_count (APNs already set aps.badge), so Push.deliver badges a closed app on either platform. Open-app Android badge(n) uses a silent carrier notification — the honest ceiling, since Android has no arbitrary icon counter. See Device Capabilities.
  • Push.deliver — one call to reach a user. Four transports (bridge, Web Push, APNs, FCM) chosen by where the user is; the cascade tries the bridge first and falls through to push by platform for whoever it did not reach. Returns a prune list of dead tokens to delete — conservatively, so a wrong-gateway error does not cost a good token. See Notifications.
  • The native bridge is feature-complete. Vibration, share sheet, badge count, keep-awake, biometrics, NFC and printing join notifications and camera — each falling back to the web API where the host has one. Needed a request/response protocol, since biometrics and NFC have to answer: calls carry an id, shells reply through __soliNativeReply, and everything rejects on timeout rather than hanging. Four limits are reported rather than faked — no Android badge API, no NFC radio on Macs, trackpad-only haptics, and biometrics that confirm a person rather than authenticate to a server. Plus Geo.* for the maths after a position: haversine distance, indexed bounding boxes, geohashes. See Device Capabilities.
  • A camera in a view, with QR scanning. camera_preview({"scan": "qr_code"}) renders a wired-up <video> and emits soli:scan with the decoded value. Native BarcodeDetector where the host has one, a page-supplied decoder where it does not. The reason it exists rather than leaving you to six lines of getUserMedia: it stops the tracks when the element leaves the DOM, which an instant-nav body swap otherwise misses — leaving the camera light on. See Scanning.
  • APNs — push to a closed Apple app. Apns.send(device_token, payload, options) completes the picture the native bridge starts: the bridge reaches a client that is looking, APNs reaches one that is not. Token-based auth (one .p8 per team, no annual certificate churn), the aps envelope built for you, and a {status, reason} result rather than an exception, because a dead device token is an outcome to handle. Receiving requires the aps-environment entitlement and so a paid Apple Developer account.
  • Native bridge — notifications from a packaged app. Native.notify(channel, payload) raises a real OS notification in a desktop or mobile shell, where neither the Push API nor the Notifications API exists (both web views reserve them for the browser). One helper in your layout — native_channel(...) — turns it on; channels travel as signed tokens, since subscribing is a browser GET. It reaches only clients with the app open and returns how many, so real push stays one line away. Existing new Notification(...) code keeps working inside a shell. See Native Bridge.

v1.23.4 — 2026-07-21

macOS packaging release. Three separate bugs made soli build --standalone and soli desktop build artifacts unusable on macOS: they refused to start after signing, rendered every page as null, and shipped without their images. All three are fixed.

Dev tools

  • Bundles carry static assets. Only text formats were bundled, so every standalone and desktop artifact shipped an app whose logo, favicon, offline page and web-app manifest were 404 — silently, and only once packaged, since from disk in dev they served fine. Images, fonts, media and documents are now bundled and served, with the MIME table extended to match (including application/manifest+json, without which browsers ignore a PWA manifest entirely).
  • Desktop apps embed in a native shell. SOLI_DESKTOP_NO_WINDOW=1 stops a desktop artifact from opening a browser window, so a Cocoa/WebView wrapper can provide the window itself instead of getting two. The launch URL is still printed on its own line — the wrapper needs it, because it carries the single-use token the local gate requires. See Desktop Applications.

Fixes

  • Signed macOS apps started the REPL instead of the app. codesign aligns its signature blob to 16 bytes, so an appended payload that did not end on that boundary left up to 15 bytes of padding — and the loader, which anchored the footer at the signature offset exactly, read the padding, concluded there was no payload, and fell through to the ordinary soli CLI. A double-clicked app printed >>>. Builds now pad to the boundary, and the loader walks back over an alignment gap so artifacts built earlier boot as well.
  • Every page of a macOS app rendered as null. The bundle extracts under the temp directory and the virtual filesystem is rooted there, but the serve path canonicalizes the same directory — and on macOS the temp dir is /var/folders/…, a symlink to /private/var/folders/…. The two disagreed about where the app lived, so view helpers never loaded, no template was ever found, and each action's nil return was serialized as a four-byte null body. The extraction directory is canonicalized before anything records it. Reproducible anywhere by pointing TMPDIR at a symlink.

v1.23.3

July 21, 2026

Testing

  • A lost coverage dump now says so instead of quietly reporting less — controllers, helpers and middleware execute in the test server subprocess, so the runner scrapes each worker's /__coverage__ before killing it. Every failure on that path — server not answering, timeout, malformed dump — was swallowed, and the report simply came out lower: one unlucky worker could drop an entire application's server-side coverage and the only symptom was a number that read as “these lines were never tested”. The runner now prints a warning naming the port and the reason, and says which measurements are missing from the report below it. In a local reproduction the difference was 31.5% reported as 1.7%, with nothing on screen to explain it.

v1.23.2

July 21, 2026

Testing

  • Browser specs declare their viewport — viewport("mobile") in a describe body sets the size every test in that suite renders at, and nested suites inherit it; calling viewport(...) inside a test resizes there and then, for a spec whose subject is the resize. Accepts a preset (mobile, iphone, iphone_se, android, tablet, ipad, laptop, desktop, wide), a "WxH" string, or explicit numbers with {"scale": 2, "mobile": true}. The device presets emulate the device rather than just a narrow window — pixel ratio and touch included, so matchMedia("(pointer: coarse)") matches. Every spec now starts at a fixed 1280×800 instead of whatever the browser happened to open with, and the viewport resets between tests along with page errors and storage. See Browser Testing — Viewport.

Packaging & desktop

  • Fixed: macOS standalone and desktop artifacts lost their app when signed — a Mach-O may not carry data past __LINKEDIT, so an appended bundle made codesign reject the file (“main executable failed strict validation”), while rewriting signers accepted it and silently discarded the payload. Darwin builds now strip the inherited signature, append, and grow __LINKEDIT over what was appended, so the artifact stays a well-formed Mach-O; the boot side reads the footer from behind the code signature when signing has pushed it off the end of the file. Re-sign with codesign specifically — a signer that regenerates __LINKEDIT still drops the payload.
  • Fixed: encrypted bundles could not boot on macOS — extraction required /dev/shm, which macOS does not have, so every encrypted standalone — and every desktop app, which is always encrypted — refused to start. They now extract to the per-user, per-boot temp directory the OS already isolates and sweeps.

v1.23.1

July 19, 2026

Auth & security

  • Soli can be an identity provider — soli generate oidc_provider scaffolds a working OpenID Connect provider implementing the Authorization Code flow with PKCE, so other applications can “Sign in with your app”. It emits five models, five controllers, a consent screen, the discovery and JWKS documents, and the index migration; the flow was verified end to end against a live server, including an independent RSA signature check of the id_token using only the published JWKS. Soli previously had no provider surface at all, and Rails, Laravel and Django all need a third-party package for this. See OpenID Connect Provider — which is explicit about what is not implemented (dynamic client registration, JAR, implicit/hybrid, client-credentials, device code, front/back-channel logout, DPoP, introspection).
  • Fixed: generated migrations were silently ignored — the migration runner parses <version>_<name>.sl, but soli generate auth emitted 1784481604create_users_1784481604.sl, whose version part is not numeric. Every migration it produced was skipped without a word: soli db:migrate up reported “No pending migrations” and moved on. Because SoliDB creates a collection on first model access, the app still worked — it just ran with no indexes and no unique constraints anywhere, which is the kind of failure you find in production rather than in development.
  • RsaKey.public_from_pem — reads a bare RSA public key (SPKI or PKCS#1 PEM) into its components. X509.public_key only handled certificates and private_from_pem only private keys, so there was no way to publish a JWKS entry for a key you hold as a public PEM — which is exactly the shape a rotation’s outgoing key takes.

v1.23.0

July 19, 2026

PDF

  • Table cells accept rowspan — a cell can now merge downward as well as across. It claims its column slots in the rows beneath (which then carry fewer cells) and is drawn once, tall enough to cover every row it spans; pair it with valign to centre the label. Composes with colspan, and a spanning cell no longer inflates the height of its first row. Applies to a table's literal rows — a data-bound table repeats one template row, so there is nothing to span.
  • Studio: the chrome got out of the way — the top bar had grown until it wrapped, so zoom, paging and the grid moved to a slim status bar along the bottom, where page-layout tools have always kept them, and Copy / Paste moved into the JSON drawer beside the JSON itself. The top bar is now a single 36px line: document zone, what to open, undo/redo, and the export actions. The status bar also carries a live geometry readout of the selection, and in development it reserves the height of the dev bar rather than hiding underneath it.
  • Studio: Flow and Free layout modes — a page can be built either way, and the document says which it is rather than the editor holding hidden state. In Flow elements follow one another down the page and dragging reorders them; in Free every element carries coordinates and dragging moves it anywhere. Switching is a real conversion, not a display toggle: going Free wraps each element in an at at the position the engine measured, so nothing shifts at the moment of the switch, and going back to Flow unwraps them in the order they appear down the page. Drawing follows the mode — a new element gets coordinates in Free and joins the order where you drew it in Flow.
  • Studio: mixed documents get their flow offset automatically — an at restores the cursor, so a freely placed element does not push the flow down and flow content would render straight underneath it. When a document mixes the two, a leading move is now calculated so the flow starts below the lowest placed element. Only a spacer at the very start of a band is managed, so nothing an author wrote elsewhere is touched, and the mode indicator reads Flow* while a document is mixed.
  • Studio: drag and drop reorders — what a drag means now follows from what the element is. A pinned element owns coordinates, so it moves freely. A flowing one does not: its position is a consequence of document order, so dragging it changes that order, with a drop line showing the gap it will fall into. Free-dragging something without coordinates only looked like it worked — it tore the element out of the flow and everything after it collapsed. For nudging, flowing elements gained a Space before control that reads and writes the move gap above them, which is how a flow document actually expresses spacing. Pin to page is still there for genuine overlays (a stamp, a logo), and Return to flow undoes it.
  • Studio: data-driven elements read as one thing — a repeat or a data-bound table draws once per item but is authored once, so its copies are shown as a single magenta-dashed element labelled with the array it binds to (× sections), rather than as a row of separately draggable boxes.
  • Studio: header and footer bands are always indicated — both are marked at all times, but an unreserved band is drawn as a boundary rather than an area. header_height: 0 reserves nothing, so the body starts at the top margin; painting a placeholder band there covered real body content and made those elements look as though they sat in the header. The band is reserved — sized to fit what you drew — the moment you actually place something in it, rather than merely by switching to the tab, so looking at a zone never edits the document. The sample picker also moved out of the top bar into the JSON drawer, alongside Paste and Copy.
  • Studio: click anything on the page — a new pdf_layout_map builtin reports where every element actually landed, so elements that flow with the document (which is most of a real invoice) can be selected by clicking them on the canvas, not just from the Layers list. They are outlined in cyan and marked as engine-positioned, since dragging them would be meaningless. Previously, opening a real template gave you a page where nothing was clickable.
  • Studio: snapping, alignment and a preview mode — a toggleable point grid on the sheet (step configurable) plus snapping that prefers meaning over the grid: an element's edges and centre lines catch the page margins, the header band edge, the page centre and the edges of other elements, with a live guide showing what was caught (cyan for a page border, amber for another element). Hold Alt to place freely. An align row snaps the selection to any content border, centres it, or fits it to the content width. Preview (P) strips every bit of editor chrome so only the page remains, and PDF opens the rendered document in a new tab for scrolling, printing or saving.
  • Studio: a full table grid editor — open any table on the canvas and edit it as a grid: add and remove rows and columns, toggle the header row, merge cells with colspan/rowspan, and set alignment, weight, size, fill, border colour and per-side borders on any cell (or push a fill across a whole row). The grid is a real HTML table using the same merges, so it shows exactly what the engine will draw.
  • New at element — places its content at absolute page coordinates (measured from the sheet's top-left corner, not the margin) and then restores the flow cursor, so the surrounding document is untouched. Because the cursor is restored, placed items are independent of one another: moving one can never shift another. That is what makes free positioning expressible in a language that is otherwise a strict top-to-bottom flow, and it is what the new studio compiles to. Coordinates are clamped to the page, and mixing at with ordinary flow content in the same document is supported.
  • New: PDF Studio — a full-screen visual canvas for building templates. Draw and move elements directly on the page, with the real rendered PDF drawn underneath the handles at exact scale, so the canvas is the output rather than a mockup of it. Rulers in points, snapping, margin and header/footer band guides, a tool rail (text, box, table, image, rule, QR, barcode), an inspector with live coordinates, and separate Body / Header / Footer zones. Exports template JSON. See PDF Studio; the structural Layout editor remains for flow-based documents.
  • New box element — a container that flows its children, then paints its background and border at the size the content actually measured, and advances the cursor below itself. It replaces the rect + hand-computed height + compensating move pattern that panels, callouts and signature areas previously needed, and whose numbers had to be re-tuned whenever the text changed. Supports padding (a number or per-side), width, fill, border, borderWidth, radius, dash and gap; boxes nest, and text inside wraps at the box's inner edge. A box whose content spans a page break omits its decoration and warns rather than painting it on the wrong page.
  • New docs page: Layout editor — build a template by structure instead of hand-writing JSON: a tree of the document on the left, a property panel per element in the middle, and a live render from the real engine on the right. It edits the template JSON directly rather than an intermediate format, so export is lossless and hand-edited files round-trip; a table whose structure the panel cannot represent is shown read-only rather than rewritten. See Layout editor.
  • Six new invoice and quote templates — ready-to-copy billing documents in www/public/pdf-samples/, each with a distinct structure rather than a recoloured header. invoice_compliant (VAT breakdown by rate, both parties' VAT numbers, statutory late-payment terms, EPC payment QR), invoice_minimal (monochrome, the amount due at 46 pt), invoice_subscription (billing period, prorated seats, metered usage, spend donut), credit_note (negative amounts, reference-to-original band), quote_sections (per-section subtotals and a signature acceptance box) and quote_options (base scope plus tickable options with a dual total). See Invoice & Quote Templates.
  • New docs page: Invoice & Quote Templates — a gallery of the eight billing samples with previews, a trait matrix for picking one by requirement, click-to-zoom sheets, and a per-template “Open in playground” link. The playground now accepts ?sample=<name> to open a specific sample directly.
  • Fixed: nested data binding — a repeat or data-bound table nested inside another repeat now resolves its data path against the current item before falling back to the document root, so "data": "lines" inside a repeat over sections binds to that section's lines. Previously it resolved only against the root and silently rendered nothing, which forced grouped line items to be flattened into one array with a kind discriminator and unpicked with nested if blocks. Grouping is now expressed directly and the data keeps the shape your application already has; the sectioned quote sample has been rewritten accordingly.
  • Documented: the typed-invoice Factur-X route has its own placeholder namespace — pdf_facturx_from_invoice builds its own render data, so it exposes invoice.* / company.* / customer.* / items[] / total.* rather than whatever your data file uses, and carries neither party VAT identifiers nor a per-rate VAT breakdown. Templates needing those should use pdf_facturx with a supplied XML.
  • scripts/gen_pdf_previews.sh — renders the PDF samples and rasterises page 1 of each to www/public/images/docs/pdf/ at 150 DPI (A4 → 1240×1755), replacing what was previously an ad-hoc manual step. Takes sample names to regenerate a subset.

Desktop apps

  • soli desktop build — package an app as a single executable that carries its own database. The user double-clicks it; it starts a private database on a loopback port, opens their browser, and serves the app. No installer, no separate database, no configuration. Your source is always encrypted, reference data can ship alongside it, and one machine cross-builds every target. See Desktop Applications — which is explicit about what this does not protect against, because the key reaches the process environment and a machine’s owner can read it. Treat it as licensing-grade protection, not confidentiality against your user.
  • Windows support — the crate now compiles for Windows, with single-instance locking, job objects so a database child can never outlive the app, and owner-only directories for decrypted content. windows-amd64 joins the release targets and a CI job keeps it from re-breaking. Compile-verified but not yet run on Windows.
  • darwin-amd64 is published — Intel Macs were resolving to that artifact name in soli update and getting a 404, because CI had never built it.

Testing

  • Browser testing is built in — soli test --browser drives a real headless Chrome over the DevTools protocol, spoken from the soli binary itself: no Node, no npm, no Playwright, nothing to install into your project. The vocabulary sits alongside the existing HTTP helpers — visit, click, click_link, click_button, fill_in, select_option, check, choose, press, evaluate, screenshot, wait_for — with waiting assertions (assert_text, assert_selector, assert_page_path, assert_no_page_errors). Clicks are real input events dispatched at the element's position, not element.click(), so an element hidden behind an overlay fails the way it would for a user. Fields resolve by CSS selector, <label> text, name or placeholder. See Browser Testing.
  • Opt-in, so the default suite stays fast — a spec is a browser spec when a browser directory appears in its path. Plain soli test sets those aside and reports how many, so a project with no browser installed still runs green and nobody pays for a browser they did not ask for. --browser checks for one up front and fails with what it looked for, rather than thirty seconds later on the first visit(); SOLI_CHROME_PATH points it somewhere else, and --headed shows the window.
  • Sign-in carries into the browser — the browser shares the request helpers' cookie jar in both directions, so an existing login() in before_each works unchanged, and a sign-in performed by clicking a form is visible to a later get() and to signed_in(). One browser per test worker, launched on first use and reused across tests, with sessionStorage, localStorage and captured page errors reset between tests so results cannot depend on test order.
  • Soli's own frontend is now covered — instant-nav, the LiveView client and the dev bar are exercised in a real browser in CI for the first time. Roughly 2,500 lines of shipped JavaScript previously had either no test at all or a JSDOM unit test that could not open a websocket or lay anything out. The new specs cover link interception and head merging, history and data-no-nav, LiveView connect/patch/morph including soli-ignore islands and focus retention, and the dev bar's panels and Alt+D toggle.

Auth & security

  • Fixed: jwt_verify rejected every token carrying an audience — the underlying library validates aud by default, and Soli never told it which audience to expect, so any token with an aud claim failed with InvalidAudience. In practice that meant no OpenID Connect id_token could be verified at all — not from Google, not from Auth0, not from anywhere — and jwt_decode_unsafe could not even inspect one. Audience is now checked only when you ask for it, the same way iss has always worked.
  • jwt_verify can now check who a token was meant for — new audience, issuer, subject and leeway options. Setting audience or issuer makes that claim required, so a token missing one cannot slip through a check you believed was enforced. If you issue tokens for more than one client, pass audience — without it a token minted for client A is accepted by client B.
  • jwt_sign can set the header kid — plus typ, and the registered claims exp (absolute), nbf, aud (string or array, per RFC 7519), iss and jti. kid is what lets a verifier pick the right key out of a JWKS, so it is the difference between one key forever and being able to rotate. Supplying both exp and expires_in raises rather than silently picking one — they are different units, and guessing would produce a token expiring at a time you never meant.
  • Secure random values — Crypto.random_hex(n), Crypto.random_bytes(n) and Crypto.random_token(n = 32), all drawn from the OS entropy source. There was previously no way to generate a secure random value from Soli — only uuid_v4() and nanoid(), and Crypto.random_hex was referenced by the documentation without existing. random_token returns unpadded URL-safe Base64, the right shape for OAuth state, PKCE verifiers, authorization codes and refresh tokens. n is a byte count throughout, so random_hex(32) gives 64 characters, matching openssl rand -hex 32.

Language

  • URL-safe Base64 — new Base64.urlsafe_encode and Base64.urlsafe_decode use the RFC 4648 §5 alphabet (- and _ for + and /) and never pad. That is the form JWS, JWK, JWK thumbprints and PKCE all require, so hand-rolling the substitution is no longer necessary. Decoding tolerates padding, since producers in the wild disagree about stripping it.
  • Base64, Hex, RsaKey, X509 and the jwt_* functions now type-check — all of them worked at runtime but were unknown to soli check, so a script merely mentioning Base64.encode failed with Undefined variable before it ran. Any script or library doing encoding or JWT work was effectively locked out of type checking.

Fixes

  • Parameter defaults now work in production — def configure(host = "localhost", port = 8080) called as configure() bound null to both parameters under the bytecode VM, while binding the declared defaults under the tree-walking interpreter. Since the VM runs only outside --dev, that meant a function with default parameters worked in development and silently computed wrong values in production — with no error, and so no fallback and no log line. Defaults are now compiled into the callee and applied per omitted argument. An explicitly passed null stays null (it is a supplied argument), and a later default may reference an earlier parameter, as in def f(a, b = a * 2).
  • Named arguments no longer slow down the handler that uses them — a labelled call such as configure(port: 3000) or get("/", "home#index", name: "root") could not be compiled, which permanently demoted the whole enclosing handler — and everything it called — to the tree-walking interpreter for the life of that worker. Labelled calls now compile and bind at call time: a function reorders them into its parameters (filling the rest from defaults), a builtin receives them as a trailing options hash. Results were always correct; this removes the hidden performance cliff under an idiomatic style the docs recommend. Covers plain calls, new, pipelines and print.
  • match followed by try/catch no longer misbinds the exception — a match with a literal arm left the VM's value stack one slot short, so a later catch e bound e to an unrelated fragment instead of the thrown value, and one shape crashed the worker outright. Match patterns whose stack behavior could not be proven correct (array, hash, and/or, destructuring) now run on the tree-walking interpreter instead of compiling to faulty bytecode.
  • A line starting with [ is now its own statement — the postfix index operator ignored line breaks, so an expression ending one line followed by a line opening with an array literal was parsed as an index into it. A Ruby-style loop like for n in [1, 2] whose body began [10, 20].each(...) became [1, 2][10, 20] and failed to parse; the same applied to brace-less if/while heads and to two adjacent statements. Worse, it was not always a parse error — while i < 1 followed by [7].each(...) quietly parsed as i < 1[7]. A [ that opens a line is now always an array literal, as in Ruby. Multi-line method chains are unaffected (they lead with .), and same-line indexing such as rows[0] is unchanged. Reaching this bug was easy via soli fmt, which converts braced loops to the end form.
  • Bundles built on Windows are no longer silently broken — entry keys were written with the platform's path separator, so a bundle built on Windows stored app\controllers\home_controller.sl while every lookup uses /. soli build --protect still reported success and produced a valid-looking .soli with an empty controller registry and no middleware directives — failing only later at serve time, and failing on Linux too. Keys are now /-separated on every platform.
  • Path guards hold on Windows — a rooted path such as /etc/passwd is not considered absolute on Windows (it carries no drive prefix), yet joining it onto a base discards that base. Guards shaped as “reject if absolute, otherwise join under the root” therefore took the safe-looking branch and escaped anyway. The view-file resolver, the absolute-import gate, and the File.* jail now all treat rooted paths as absolute. The import gate and file jail had canonicalising checks that already blocked the escape, so those were defense-in-depth; the view-file resolver did not.
  • A try/catch can no longer swallow an engine fallback — when compiled code reached a function the VM cannot compile, the resulting internal error was catchable, so a handler that wrapped the call caught the engine's own limitation as though it were an application error and returned its rescue value — skipping the fallback to the interpreter entirely. Such fallbacks now bypass try/rescue, as intended.

v1.22.0

July 19, 2026

Language

  • break is now a loop keyword — exits the innermost enclosing while or for loop, with postfix conditions (break if cond / break unless cond). It propagates correctly out of nested blocks, if branches and try/catch (a finally block still runs before the loop exits), and is absorbed at the function boundary — a break inside a lambda or function body does not break an outer loop. Not compiled by the bytecode VM: a handler containing break falls back to the tree-walking interpreter automatically (same precedent as safe navigation &.), so it is fully functional but not JIT-compiled. See Control Flow.
  • Breaking: the break() debugger builtin is renamed to debug() — the name was freed up for the new loop keyword. Same behavior: a zero-arg builtin that triggers a breakpoint and opens the interactive debug page in development, ignored in production. Replace any break() call in your code with debug(). See Debugging.

Performance

  • Much faster soli graph build re-syncs — each node document now carries a deterministic content hash covering all of its stored fields and its embedding. On a re-sync, nodes whose hash is unchanged are skipped entirely instead of being re-written. Because SolidB re-serializes the whole node vector index on every write batch, a typical incremental sync (a handful of nodes changed) collapses from roughly one full-index rewrite per 200 nodes down to a couple. The vector index is also built after the initial bulk load rather than before, so a first build pays a single index build instead of a re-serialize per insert chunk. Graphs written by an older build are upgraded transparently on the next sync.
  • Direct native instance-method calls — obj.native_method(args) (and super.native_method(args)) no longer allocates a bound NativeFunction wrapper on every call. The receiver is prepended and the underlying native runs in place (same calling convention as before). Method-as-value access (m = obj.method) still binds a wrapper. Covers the bytecode VM CallMethod path and the tree-walker call dispatcher. Model-subclass instance natives still fall back to the tree-walker on the VM so lifecycle callbacks fire (unchanged carve-out). On a hot DateTime accessor loop: about 1.55× faster on the tree-walker and 1.62× on the VM (~−36% / −38% wall time).

v1.21.4

July 16, 2026

Dev tools

  • C# call graph in soli graph build — the multi-language extractor now walks C# method bodies for calls and new X() instantiates edges, attributed to the enclosing method — so an agent can traverse "who instantiates / calls what" on a C# repo, not just its type/inheritance skeleton. Precision-first: instantiates links only to project classes (framework types like new List<T>() are skipped), and calls links only unambiguous names (overloaded/shared names are dropped, not mis-linked).

Fixes

  • soli graph build no longer hangs on a slow embedding endpoint — embedding HTTP requests (run by default) had no timeout, so a stalled or unreachable SOLI_EMBEDDING_URL would block the whole build forever. Requests now time out after SOLI_EMBEDDING_TIMEOUT_SECS (default 60s) and fail with an actionable message that distinguishes a missing key from an endpoint that timed out. --no-embed remains the escape hatch.

v1.21.3

July 16, 2026

Dev tools

  • Richer code-graph edges — soli graph build now links instance method calls on locally typed variables (let u = new User(), typed lets, User.find / factories), partial(...) / view→partial renders, redirect("/path") as redirects to matching routes, and bare super(...) / super.method(...) to the parent method. Local type tracking is flow-aware: reassigning a tracked local to a value with no known class drops its type, and a bare partial("form") in a view resolves against that view's own directory first. Still precision-first: unbound receivers stay unlinked.
  • soli graph query --kind + better agent output — filter seeds by node kind (--kind method,controller); each seed includes a truncated snippet (the human summary shows real doc/body context, not repeated metadata); keyword fallback weights name/qualified_name over body text; neighbours are ordered with structural edges first (routes_to, calls, renders, …); a redirect to a path served by several verbs prefers the GET route.

v1.21.2

July 16, 2026

Dev tools

  • soli graph query --path <prefix> — scope code retrieval to a subtree (e.g. --path api/ or --path app/), so an agent can target one side of a mono-repo without post-filtering the JSON. Only seeds whose file starts with the prefix are returned; neighbours are unaffected. Semantic search over-fetches then filters (so an out-of-path top ranking doesn't starve results), and the keyword fallback filters server-side in AQL.

Fixes

  • soli graph build on graphs > 1000 nodes — the SolidB client now follows the query cursor to completion instead of reading only the first 1000-row batch. Previously a build of a large codebase aborted with Document with _key '…' already exists and re-embedded the tail on every run. Bulk mutations in the sync path also gained retry with exponential backoff, so a transient timeout on one chunk no longer aborts the whole sync.

v1.21.1

July 16, 2026

Fixes

  • arm64 Linux release build — OpenSSL is now vendored (compiled from source), so the linux-arm64 cross-build no longer installs libssl-dev:arm64 from the arm64 multiarch mirror (ports.ubuntu.com), which is unreachable from some CI runners. v1.21.1 ships the same features as v1.21.0 with a working release.

v1.21.0

July 16, 2026

Security

  • Rate-limiter memory hardening — the process-global rate-limit store is now bounded (10,000 distinct keys) and auto-reclaims expired buckets, so an attacker minting a fresh key per request (rotating spoofed IPs, random tokens) can no longer grow it without limit. When the cap is reached a new key evicts an existing bucket rather than sharing one, so every live key keeps its own independent counter — limits are never mixed across keys or across different rate-limit rules.
  • ReDoS limits on every regex-backed string method — gsub, match, scan, split and model/format validation now compile patterns through the same size- and nesting-bounded cache the Regex class already used, so a request-controlled pattern can no longer force an unbounded compile. Behavior change: a pathologically large or deeply-nested (> 10) pattern that previously compiled now raises invalid regex: ….
  • Constant-time Crypto.secure_compare — no longer early-returns on a length mismatch, removing a timing side-channel (length oracle) when comparing CSRF tokens, HMACs and other secrets.
  • Static-file symlink hardening — the static file server resolves and serves the canonical path, closing a TOCTOU window where a symlink planted under public/ between the jail check and the open could escape the public root.

Performance & Memory

  • Leaner multipart uploads — a multipart/form-data body is no longer also copied into a lossy UTF-8 string alongside its raw bytes and parsed parts (a large upload was triple-buffered). Behavior change: req["body"] is now empty for multipart requests — read fields from params / the form hash and files from the uploads API (the raw bytes are still retained). CSRF verification and _method override are unaffected.

Dev tools

  • soli graph build — code graph in SolidB for agents (graph RAG) — extracts a graph of your project's source (nodes: files, classes, models, controllers, methods, functions, routes, views; edges: defines, inherits, imports, calls, renders, routes_to, relates) into soli_graph_nodes + soli_graph_edges, so agents retrieve code by semantic search and traverse relationships from there. Every node's text is embedded (vector index over embedding); --no-embed builds a purely structural offline graph and --dry-run prints the whole graph as JSON without touching SolidB or the embedding API. Connects to the same SolidB the app's Models use. Incremental & non-destructive: MD5-hashes files to skip unchanged re-runs, and updates SolidB in place (insert/update/prune) instead of dropping — unchanged embeddings are reused, only changed text is re-embedded; --fresh forces a clean rebuild. Progress bar over the parse/embed/sync phases so big projects don't look frozen.
  • soli graph query "<question>" — one-call retrieval for agents — turns a natural-language task into the most relevant code plus its immediate graph relationships (semantic ANN seed → 1-hop graph expansion → ranked). --json returns a structured result (each seed + its neighbors) an agent parses directly; --limit/--hops tune breadth/depth. Falls back to a keyword-ranked scan when the graph has no embeddings, so it always works.
  • Dev auto-reindex — run soli serve --dev with an embedding key configured and the code graph reindexes itself on every .sl/.slv save, so it never goes stale while you work (no flag needed; SOLI_GRAPH_WATCH=0/1 forces off/on). Rides the dev file-watcher on a background thread, reuses the live route table (never re-executes routes.sl), and is incremental on embeddings — unchanged nodes keep their vector, only what changed is re-embedded.
  • Any codebase (multi-language) — soli graph build now indexes any repository, not just Soli apps. Pass --ext rb,erb,slim (or commit a .soligraph.toml) and a tree-sitter extractor pulls real class/method/function nodes + inherits/implements/imports edges for Ruby, Python, JS/TS, Rust and C#; other extensions are chunk-embedded for semantic search. SolidB settings come from the project's .env; incremental sync, soli graph query and the rest are reused unchanged.

v1.20.0

July 14, 2026

Performance & Memory

  • Smaller runtime values — size_of::<Value>() dropped 64 → 24 bytes (−62%): the native-function and method variants are now behind a pointer. Every array cell, hash slot, model-row field and env binding is one Value, so this shrinks runtime memory across the whole interpreter.
  • Smaller AST — parsed nodes shrank (Expr 144 → 80 B, Stmt 360 → 200 B) by narrowing source spans to 32-bit and boxing the large declaration and lambda payloads. Each worker holds its own copy of the parsed app, so per-worker RSS drops proportionally to app size — biggest for code- or i18n-heavy apps. Compile-time size_of guards keep the wins from regressing.
  • Boot interpreter reclaimed — the extra interpreter built only to register the shared route/model/controller/template registries before workers start is now freed right after boot, instead of sitting idle (holding all builtins + the parsed app + i18n tables) for the process lifetime.
  • Leaner background jobs — SOLI_JOB_VIEW_HELPERS=0 skips view helpers (incl. i18n locale tables) in job interpreters, the default job-pool size is now 1, and a new Keeping memory low guide documents SOLI_WORKERS and friends — the worker count is the primary lever on baseline RSS.
  • Fix: SOLI_WORKERS is now honored — the CLI previously ignored the env var (only the --workers flag was read, defaulting to CPU cores), so operators on many-core boxes couldn’t cap the worker count from the environment. On a 16-core box this alone takes a locale-heavy app from ~311 MB (16 default workers) to ~66 MB at SOLI_WORKERS=2; --workers still overrides.

v1.19.0

July 14, 2026

AI & Search

  • Client-side reranking — rerank(query, rows[, { field:, limit: }]) reorders an array of retrieved records by query-token overlap — most relevant first, ties stable. Pure and offline (no LLM, no server round-trip), so it's a cheap second pass after similar / hybrid / graph_rag when you want to bias the order toward a phrase.
  • Server-side auto-embeddings — declare embedding_source on a vector index and SolidB embeds inserted text itself on a background worker (off the write path — bulk and driver inserts never block), so you can skip the per-model before_save embed(...) hook.
  • New raw-SDBQL retrieval surface — reachable via db.query / @sdbql{} and now documented: filtered VECTOR_SEARCH(…, { filter }), LLM/lexical RERANK, stored RAG_PIPELINE, point-in-time DOC_AS_OF / DOC_HISTORY, and self-refreshing CREATE MATERIALIZED VIEW … REFRESH "5m".
rerank pipeline: take retrieved rows, score each by query-token overlap offline, reorder most-relevant first, and keep the top-k
rerank(): score retrieved rows by query-token overlap, reorder most-relevant first, keep the top-k — offline, no LLM.

Testing

  • N+1 & query-budget assertions — request specs can now fail on database regressions. assert_no_n_plus_one(response) fails when the same AQL template fired 2+ times in a loop (the same detection behind the dev bar's N+1 badge); assert_query_count(response, n) and assert_max_queries(response, n) hold an endpoint to a query budget. Every response also exposes response["query_count"] and response["n_plus_one"] for custom checks.
  • soli test --fail-on-n1 — a suite-wide N+1 tripwire. With the flag, any get()/post()/request() that triggers an N+1 fails its test automatically — the same detection and message as assert_no_n_plus_one, but with no per-test call and no spec edits. Clean and uninstrumented responses are untouched, so it never trips spuriously; wire it into CI to catch a query regression the moment it lands, even in specs that predate the check.

Language

  • Removed the dead async/await keywords — they were never implemented (writing await <expr> hit an internal panic), so this deletes unreachable surface only. The await() builtin that resolves a future (e.g. the handle from System.run(…)) is unchanged — await(x) is now an ordinary function call, and async/await are freed as identifiers.

Fixes

  • VM named-argument robustness — named-argument calls (f(x: 1), x |> f(a: 1)) now compile as a clean fallback to the tree-walking interpreter instead of emitting broken bytecode that could misdispatch to the wrong function or panic a worker thread. Results were always correct via the interpreter; this removes the crash/misdispatch edge in the compiled engine.
  • Recursive flatten in the VM — array.flatten() under the compiled engine now flattens fully (and accepts an optional depth), matching the interpreter. It previously flattened only one level, so [[1, [2]], 3].flatten() returned [1, [2], 3] instead of [1, 2, 3]. Both engines now share one implementation, pinned by differential tests.

v1.18.0

July 12, 2026

Grouped by area; jump to a section:

Real-time & LiveView

  • Reactive live queries — Model.live_where(filter) inside a LiveView handler runs the query like where(filter).all() and subscribes the view, so a later write re-renders it automatically. Per-row matching wakes only subscribers a flat-equality filter's changed row satisfies (numeric-aware; null matches a missing field); the string form, deletes, and transaction commits wake conservatively, and transaction writes wake on commit. Single-process.
  • LiveView collection streams — a handler can return a stream sub-hash to push targeted DOM ops (append/prepend/insert/remove/reset) straight to a container by id instead of re-rendering the whole list (Phoenix streams / Turbo Streams model). Streamed rows live outside the diff shadow — ideal for chat logs, feeds, leaderboards.
  • Debounce, throttle, JS commands, and navigation. soli-debounce / soli-throttle (milliseconds) on any event element; window-level soli-window-keydown / soli-window-keyup; soli-href for a full-page leave. A handler may return redirect: "/path" (the client navigates, no further patch) or js: [{ op, to, … }] — eval-free commands (add_class/remove_class/toggle_class, set_attr/remove_attr, focus, dispatch, navigate, patch). Uploads and nested live components remain out of scope. See Live View.
  • Hooks, loading states, and click-away. soli-hook="Name" binds a client hook (mounted/updated/destroyed/disconnected/reconnected, plus this.pushEvent) — register on SoliLiveView.hooks before connect. In-flight events add soli-loading / soli-<event>-loading; soli-disable-with swaps the label and disables the control until the next patch. soli-click-away closes dropdowns when a click lands outside.
  • In-socket navigation and nested child sockets. soli-patch="/path?q=1" updates the address bar and sends event == "patch" with href/path/query (browser back/forward too). A handler may return patch: "/path" or { "url": "/path", "replace": true }. Nested data-liveview-url mounts become their own sockets after each parent render (implicit ignore island). Phoenix-style live_component assigns remain out of scope.
  • File uploads. soli-upload="handler" POSTs each file to /live/upload (multipart + CSRF, 8 MiB default) and then sends the handler params["file"] in the same shape as find_uploaded_file (base64 data). Progress via soli-upload-loading / data-soli-progress. Not chunked or resumable.
  • Nested components share parent assigns. live_component("score", { "score": true }) renders the child template from parent-owned keys. A child event may send soli-assign-*; the runtime merges _assigns onto the parent before the handler runs. Isolated data-liveview-url sockets remain.
  • Root swap and reconnect restore. soli-live="/live/socket/name" (or handler { "live": "..." }) disconnects this socket and connects another component on the same root. A dropped socket reconnects with the previous instance state so the connect handler sees in-flight values. A second tab of the same session attaches another sender, so a click in one tab patches the others.
  • Unified broadcast() — broadcast(channel, payload) (and the Model.broadcast(payload) shortcut) fans a payload out to every WebSocket connection in a channel and every SSE subscriber of the topic of the same name, in one call. Non-string payloads auto-serialize to JSON; returns the SSE subscriber count.
Reactive live queries: a write is matched per-row, then only matching LiveViews are woken, diffed, and patched
Live queries: a write is matched per-row, then only matching views are woken and diffed.
Unified broadcast fans a payload out to both a WebSocket channel and an SSE topic of the same name
broadcast(): one call reaches WebSocket and SSE subscribers of the same name.

AI & Search

  • Embeddings & generation builtins — embed(text) / embed_batch(texts) and llm_generate(system, user) talk to any OpenAI-compatible endpoint (configured via SOLI_EMBEDDING_* / SOLI_LLM_*), keeping credentials out of app code.
  • One-call RAG — Model.rag(question[, opts]) embeds the question, ANN-searches the vector_index for the top-k rows, builds an LLM context from each row's text field, and returns { answer, sources }.
  • Streaming LLM — inside an sse block, out.llm_stream(system, user) streams a completion token-by-token to the client and returns the full answer to persist.
  • Graph-augmented retrieval — Model.graph_rag(query, { via:, seed_k:, limit: }) seeds with ANN on the vector index, expands each hit through an edge model, then re-ranks the union — retrieval that reaches related context one hop away.
Model.rag pipeline: embed the question, ANN-search the vector index, build context from the top rows, generate an answer, and return answer plus sources
Model.rag: embed → ANN search → build context → generate, returning the answer and the source rows.

API

  • Opt-in OpenAPI — SOLI_OPENAPI=1 exposes an OpenAPI 3 spec generated from the routes at /openapi.json and a Scalar API-reference UI at /openapi (:id → {id} path params, controller#action operationId, controller tags). 404 unless enabled; served in every environment once on, like /_metrics. SOLI_OPENAPI_TITLE sets the title.

View Components

  • Component system — reusable ERB view components (component "card", title: X do … end) with a default slot from the block body and named slots via a slot-builder block (do |c| c.slot("header") do … end end).
  • Collection rendering — component("card", {"collection": items, "as": "post"}) renders once per item with per-item <as> / <as>_index / <as>_counter locals.
  • Fragment caching — component(…, {"cache": key, "cache_ttl": n}) memoizes rendered output through the KV cache (best-effort, only when the render isn't request-dirty).
  • Declared props — props("a", "b") documents a component's inputs; missing/unknown props surface as dev-bar + console warnings, and a component/props lint rule checks well-formedness.
  • @ivar propagation — controller instance variables (@current_user, @posts) flow into partials and components automatically, without threading them through every render.
  • soli generate component plus paginate and number_with_delimiter view helpers.

Developer Tools

  • Database browser — dev-only /__soli/db: browse collections, paginate rows, view a document as JSON, and run a read-only SDBQL query (mutating queries rejected, collection names allow-listed).
  • Component preview catalog — dev-only /__soli/components lists view components with their declared props and a live preview (example data from a <%# preview: {json} %> header).
  • Mailer preview gallery — dev-only /__soli/mailers renders each email body in an iframe with the same <%# preview %> convention.
  • Request replay — a ↻ button on each dev-bar request row re-dispatches a captured request through the real worker path to reproduce a bug server-side (CSRF skipped, tagged X-Soli-Replay: 1).

Mail

  • IMAP client — an Imap builtin mirroring the existing POP3 client (connect, list, fetch, search) over shared mail-parsing internals, so apps can read mailboxes as well as send.

Testing

  • Factories, transactional examples & time freezing — define reusable record factories, wrap examples in a rolled-back transaction for isolation, and freeze the clock inside a test for deterministic time-dependent assertions.
  • Coverage fix — constructor bodies are now attributed to the class's own source file, so coverage reports line up with where the code actually lives.

v1.17.0

July 7, 2026
  • Tamper-evidence & ledger crypto — Crypto.canonical_json, Crypto.merkle_root, and Crypto.ledger_hash builtins plus Model#to_h; Crypto is now registered in the type-checker.
  • Formatter — stop injecting blank lines before body comments; wrap over-long signatures and calls.

v1.16.1

July 6, 2026
  • Devise-style auth suite — soli generate auth scaffolds password reset, email confirmation, remember-me, and account lockout.
  • Form builder — Rails-style form_with (with block syntax), per-form CSRF tokens, and _method override.
  • Strong params & nested params — Rack-style bracket nesting across form/multipart/query, fields_for sub-builders, and permit().
  • Built-in CORS — cors("/api/*", {...}) with preflights and origin-checked CSRF opt-in.
  • Signed & encrypted cookie jar — set_cookie(..., {"signed"/"encrypted": true}) + read_cookie.
  • Self-executing bundles — soli build --standalone with cross-target runtimes; soli_version minimum-version gate in soli.toml.
  • JSONP read/write (render_jsonp, JSON.parse_jsonp, HTTP.get_jsonp); dev-mode attr_accessible mass-assign-drop warning.
  • Fix — background-jobs callback route preserved across config/routes.sl hot reload.

v1.16.0

July 5, 2026
  • Single-collection inheritance (STI) — class Admin < User shares the base's collection with a type discriminator, inheriting its validations, callbacks, relations, and scopes.

v1.15.1

July 5, 2026
  • Association writers — owner.posts << record and owner.posts.create({...}) stamp the foreign key through the normal save path.
  • Cleared the clippy findings that had sunk the v1.15.0 release CI.

v1.15.0

July 5, 2026
  • ORM associations — polymorphic (belongs_to polymorphic: + has_many as:), has_many through:, counter caches (counter_cache:), cascade deletes (dependent:), and dirty tracking (changed?/changes/previous_changes).
  • New model layers — graph edges + traversal, insert-only timeseries with time_bucket, grouped analytics aggregation, columnar stores, and vector/fulltext/geo search pushdown.
  • DOM-aware LiveView morphing — widget/focus/scroll state survives patches.
  • Encrypted cookie sessions, the soli routes lister, content_for / named yield, and conditional (if:/unless:) + per-operation (on:) validations.

v1.14.0

July 5, 2026
  • PDF signatures — PAdES digital signatures + RFC 3161 trusted timestamps (PAdES-B-T), sign/verify existing PDFs, and a visible signature appearance.
  • PDF toolkit — read incoming Factur-X (pdf_extract_facturx), pdf_merge/pdf_pages/pdf_stamp, pdf_fill (AcroForm), and pdf_from_markdown.
  • Accessible + archival — unified PDF/UA (L/Table structure tagging) with PDF/A in one veraPDF-conformant file; tables & charts flow inside columns.
  • Encrypted & protected .soli bundles; soli build accepts flags in any position.

v1.13.8

July 3, 2026
  • In-process background job pool — run long jobs on a worker pool without an external queue.
  • Solar redesign — the landing page and docs were re-themed; PDF/UA semantic tagging (headings, figures + alt, artifacts, XMP) and PDF/A-3b.
  • SOLI_HOST to restrict the bind address.

v1.13.7

July 1, 2026
  • PDF performance — cache font registries and precompute glyph metrics for faster document rendering.

v1.13.6

June 30, 2026
  • Security — patched the ammonia mutation-XSS advisory.

v1.13.5

June 30, 2026
  • Fix — soli db:migrate creates the SoliDB database if it doesn't exist yet, instead of failing with a 404.