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, andmock_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_specforcontrollers/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.slin 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 takesSOLI_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_queriestable 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 withSOLI_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/_TOKENorSOLI_ADMIN_*), at most 1000 shapes per app, off underAPP_ENV=test;SOLI_SLOW_QUERIES=offdisables 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) andslow_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 withX-Soli-SignaturewhenSOLI_NOTIFY_SECRETis set, all through theWebhook.enqueueSSRF guard),SOLI_NOTIFY_EMAILSthrough the app’s own mailer (the dev inbox in--dev), andapp/jobs/soli_notification_job.slwhen the app has one, enqueued with the event hash for PagerDuty, SMS or anything else. At most one message per event and group perSOLI_NOTIFY_THROTTLE(default 15 minutes);SOLI_NOTIFY_EVENTSnarrows 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.mdwritten bysoli new(refresh an existing app withsoli update docs) lists error tracking, slow queries, notifications and instant navigation, andapp/views/CLAUDE.mdexplains 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 setSOLI_NAV=morphfor 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 byid, then by tag; unchanged scripts are not re-run; Alpine components are replaced whole; pages withx-teleportfall back to a swap. Instant Navigation
Fixes
x = f() rescue nilno longer makesxaNull. The type checker gave a postfixrescuethe type of its fallback alone, so thex[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 0stays anInt, anythingrescue nilisAny. A barex = nilnow reads likelet x = nil, so the next assignment is no longer refused as expected Null.
v2.5.3 — 2026-09-25
Added
X509.info(cert)andX509.peer_certificate(host, port?, timeout?). Watch certificate expiry without shelling out toopenssl:inforeads the validity dates,days_left(negative once expired), subject, issuer and SAN names;peer_certificatedoes 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 asHTTP. See Built-ins.
Auth & security
- The data passed to
render()is redacted in error samples. When a template failed mid-render, itsrender()hash reached the stored error sample, the/__soli/errorspage and the stderrenv:line unredacted — areset_tokenpassed to the view was shown in full. It now gets the same redaction as the handler’s locals. X-Api-Keyandapi-keyare 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’scurlline. Names now match whatever the separator, request headers also get the exact credential-header list, and thecurlline never carriesAuthorization,Cookieor 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 Xandcan't dividewere one group, and a quoted value afterUser'sleaked 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
500when 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 acurlline 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=offto stop); in production the page stays404untilSOLI_ERRORS_USER/_PASSWORDorSOLI_ERRORS_TOKENis 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_TOKENopen both/__soli/jobsand/__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 printedreq.body— the rawpassword=…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 installnow include the MessagePack driver. Until now,SOLI_DB_DRIVER=1on a published binary silently stayed on HTTP, so the read speed-ups below never reached you. Nothing changes unless you opt in: setSOLI_DB_DRIVER=1to use it. CI now tests it against a real SoliDB. See Configuration forSOLI_DB_DRIVERandSOLI_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(...).alland other plain query-builder reads now receive Soli values directly fromsolidb-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/dband 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 fmtno 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 anif … endblock, and when the next line started with[the formatter still added a;, alone on its own line, which broke the file. Blocks end withendand now get no;.
EUI
- Styles in Tailwind’s classes. The scaffolded catalogue gains a sixth file,
eui_builders_tw.sl, andtw("flex items-center gap-3 rounded-lg bg-white px-4 py-2 shadow-sm hover:bg-gray-50"): the resting style and itshover:,active:,focus:anddisabled:deltas. A"tw"key in the style given tonode,column,roworstackis read the same way, with the states wired as local handlers;controlandstatefultake 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": widthbeside"tw"on a node — and a breakpoint class with no width raises rather than guessing.space-x-4on a row,space-y-2on a column andgap-x/gap-yalong the line become the gap;divide-y divide-gray-200is laid onto every child but the first bynode();uppercase,lowercaseandcapitalizeare applied to the string bytext().mx-auto,block,relative,isolate,select-noneandfocus-visible:are taken where EUI already behaves that way; a half step such aspy-1.5is still refused, and names the two nearest steps. See Styling.- The half spacing steps:
py-1.5,px-2.5,gap-3.5, and20and32. They were the classestw()refused most often in first-draft screens —1.5alone 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), andtw()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
pulseandbounce. A style’sbgmay 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.animationtakes"pulse"and"bounce", which play on the client’s clock with nothing on the wire.tw()takesbg-gradient-to-r from-indigo-600 via-info to-[#ff80b5]andanimate-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-mdbadges and chips; underlined tabs and a sidebar whose current row is a grey wash.table_rowtakes{"dense": true}for a virtualised list, andaccordiontakeschildrenas well asbody. - A field says what goes in it while it is empty.
input,textarea,fieldand every*_fieldbuilder 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 indexto 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"]andcurrent_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/templatetook 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 theResultnow, and the baseline locks in the parser’s drop from 130 to 128.
v2.4.0 — 2026-09-24 — tagged, never published
- A Workspace mailbox is reachable again. A Google administrator can switch app passwords off for a whole domain, and by default now does —
LOGINthen 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 taggedNO, 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_uidask 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_uidask for four header lines, so a list is kilobytes;fetch_headers_range(lo, hi)andfetch_headers_set("100:*")do a whole run in one round trip rather than one per message. Each row carriesbytes(the message's realRFC822.SIZE, which a headers fetch cannot otherwise know) andclips, the attachment count read offBODYSTRUCTUREwithout 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 nround trip first, measured at ~200 ms against Gmail, in which the application answers nothing.uid_mark_seen,uid_mark_unseen,uid_delete,uid_moveanduid_copyare the same operations in one turn instead of two. - A message's third face is visible.
text_bodyandhtml_bodyanswer “the plain one” and “the HTML one”, and neither will ever return atext/markdownpart — it is text like any other. Parsed messages now also carryparts: 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.deliveracceptsalternatives— an array of{ "content_type", "body" }— and builds amultipart/alternativeordered 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 amultipart/mixedwrapping the alternatives. - An answer reads as an answer.
Mailer.deliveraccepts aheadershash, which is whatIn-Reply-ToandReferencesneeded — 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.
toandccwere handed over as bare strings, so"Ana <ana@x.io>"was written asTo: <Ana <ana@x.io>>— one address inside another, which is not an address at all. They are parsed the wayfromalways has been.
- 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 — andpdf_preview_from_markdownandpdf_preview_responsemirror their PDF counterparts. Size it withdpi(96 by default) or withwidth/heightin pixels, which win over it; pick pages with the 1‑based selectionpdf_pagesalready takes; passout_dirto 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 — astationeryletterhead, 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. Becausedpiusually arrives in a request, each knob is capped, andout_dirwrites through the same jail asfile_write_base64. - And three times smaller as WebP. A page is flat colour and crisp type, which is what PNG is worst at: the
invoicesample 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 encoderImage.format("webp")already uses, because theimagecrate’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.pngstays the default — lossless and universal is the safer thing to default to — andjpegis there for completeness. - The documentation gallery stopped needing poppler.
scripts/gen_pdf_previews.shshelled out topdftoppmat 150 DPI, topdfinfofor a page count and topython3to read a PNG header — three external tools, and a separate cargo build of thepdf/workspace, to rasterise pages the engine had just laid out. It is a Soli script now, andsoliis the only thing it needs. Themarkdownsample joins the gallery, having been excluded for having no template to feedpdftoppm.
Database
- Transactions work again.
with_transactionand everytransaction { … }block died at the first statement withfailed to begin: No tx_id in response. The id of a newly opened transaction was read out of atx_idfield; 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 waydb_query_rawalways 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) byModel.find/update/deleteand the raw SoliDB client. Field names longer than 128 characters are refused inorder,find_byand hashwhere, on every adapter.likein a hash filter onincludesuses 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 withsslmodedisable,preferorrequire(MySQL: belowVERIFY_IDENTITY) logs a one-time warning recommendingverify-full. The default is unchanged. See PostgreSQL. - A client-chosen
wherekey 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
matchno 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 isPoint { 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 spellingv: Typeis untouched, and one consequence is worth having — the bytecode VM now compiles every pattern the parser can build.Int.to_iandFloat.to_f. Every type answeredto_i— a string parses, a float truncates,nullis0, a bool is0or1— except the one type that already is an int, which raisedCannot access property 'to_i' on int.Float.to_fwas the same gap mirrored. That is precisely backwards: the reason to reach for.to_iis 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_ito defend against the case that needed no conversion at all. Both are total over the scalars now, andparams["page"].to_ican 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
defcaptures 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 embeddedRetryclass 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_htmlfor 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 becomestext <url>, a quote keeps its>, a fence keeps its lines verbatim.DateTime.now()carries a subsecond. It wastimestamp() * 1_000_000_000, so every instant it made had a zero subsecond:millisecond()answered0for all of them and a duration measured between twonow()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.newinferred 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. WithSOLI_SESSION_DRIVER=cookiethe 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_withand its builder now evaluate in their own environment, so an app helper namedhorattrno longer replaces the escaping the builder relies on. See Forms.
Security
cargo auditpasses 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 throughSpreadsheet.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-xml0.31 leaves the dependency graph entirely, and so doesimage0.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/:slugansweredPOST /__devwith no Origin check./_health,/_readyand/_metricslose their CSRF exemption — they are GET-only anyway. See Routing → CSRF. --devendpoints answer only a localHost. The dev-bar diagnostics,/__dev/*, the inbox, request replay and the REPL token in dev error pages now requirelocalhost,*.localhost, an IP literal, or a host inSOLI_APP_HOSTS— a DNS-rebinding page could otherwise drive them through the developer’s own browser. A name likemymac.localormyapp.testmust be added toSOLI_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_SIZEbefore reading a byte, so a crowd of tiny bodies could exhaustSOLI_MAX_INFLIGHT_BODY_BYTES; it now starts at 64 KiB and doubles as the body grows. NewSOLI_BODY_IDLE_TIMEOUT_SECS(default 10): a body that stalls between frames gets408. A transport error mid-body is400(was413). Static files over 1 MiB are streamed from disk instead of read into memory. See Server Hardening. /_metricsbehind a proxy needs its token. WithoutSOLI_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 carryingX-Forwarded-For/X-Real-IP/Forwarded, or any request whiletrust_proxyis on. Deployments behind a reverse proxy must setSOLI_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 afterSOLI_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/5xxtemplate now receives a genericmessage(“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,0disables) caps its age from issue however active it is;session_regenerate()restarts the clock, and a cookie issued before the upgrade counts from itsiat. 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.tsaURL is checked (private/loopback refused, reply capped at 1 MiB). The blocklist gains0.0.0.0/8,192.0.0.0/24,198.18.0.0/15,240.0.0.0/4and IPv6 forms that embed a blocked IPv4 — NAT64, 6to4, Teredo, IPv4-compatible. See HTTP. Crypto.modexpcaps 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_verifyaccepts ±1 step and has no replay protection — remember the last accepted step per user and rate-limit.jwt_verifychecksaudonly when you pass an expected audience.Crypto.pkcs1_unpadis not constant-time: verification and interop only, never expose its errors from a decryption endpoint.strip_htmlis a naive tag stripper, not a sanitizer: escape its output, and usesanitize_htmlfor untrusted HTML. See Crypto. - One client can no longer hold the whole upload budget. New
SOLI_BODY_BUDGET_PER_IP_BYTEScaps the share ofSOLI_MAX_INFLIGHT_BODY_BYTESa single client may have in flight — by default a quarter of it (never less than oneSOLI_MAX_BODY_SIZE,0disables). Over it, the same503withRetry-After: 1. The client is the TCP peer, or the right-mostX-Forwarded-Forwith 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 set0. See Hardening. SOLI_TRUSTED_PROXIESnow reaches the origin checks. The CSRF Origin gate, the WebSocket and live-reload upgrade origin checks and the--devsame-origin check ignored the trusted-proxy list and believedX-Forwarded-Hostfrom 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.lockpins what a dependency contains, not only which commit.soli install/add/updaterecord 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#@integrityline to accept new content. A mismatching existing cache is kept; delete it to re-download. Path dependencies are not hashed, and oldersoliversions 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/andapp/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_MAXmoves the bound,0removes it.trace.jsonstays complete — above 64 KB it is served from/__solidev/trace/<id>instead of being inlined. See Debugging. soli checklearns 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:describeandres_body,RateLimiter,middlewarefrom the routing DSL,Mailer,form_with. The checker's list of engine classes was a second list besideregister_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, soI18n.cache_tableresolves whileI18n.tarnslateis still an error. Two sets stay apart and are seeded per file: the test DSL, for onesoli testwould run (adescribe(…)in a controller is a real error), and the request scope —req,params,session,render,redirect— for a file underapp/,config/orstdlib/, so that a loose script callingrenderstill 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 typedVoid, 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 amodule;let x = nilwasNullrather 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 checkandsoli lintknow 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_statsandeui_capabilitieswere registered at runtime and declared to neither the type checker nor the linter, sosoli checkansweredUndefined variable 'router_eui'andsmell/undefined-localflagged every call — on an application written bysoli new --euiitself.sseandstreamwere in the same state,nextwas known to the linter and not the checker (which is why it only ever worked inside a server handler), andpermitwas 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, thenImage/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, andrender/redirectsit 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 testdashboard. 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 showeddos…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.COLUMNSnow 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 holdsthis, 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.mdlists what Soli already does, so an agent stops rebuilding it. The full reference was already copied into every app underdocs/, but nothing in the root guide pointed at it by capability — an agent asked for two-factor auth never foundCrypto.totp_verify, filed deep indocs/builtins.mdunder 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 towww/docs/is not referenced there. Existing apps get it withsoli update docs. TOTP also has its own section in the builtins reference now, withtotp_generate,totp_uriand how to generate a Base32 secret; the examples that called a non-existentQRCode.encodeare 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 indexruns 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 EXISTSbefore every write; SQLite has a statement cache; thesolidb/solikvsession 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_CSRFandSOLI_CSRF_TOKENSare 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, soEUI_TRACE=1prints 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
Welcomenamed, with the sequence of the last batch it applied. When they name a session still here, the answer isWelcome{Resumed}and only the batches that client missed — noconnect, 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
clickcarrying 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=1says 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))inrespond_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 forapplication/vnd.eui.framesgets the render.eui_render(tree)returns an ordinary response with anETagover the body and a304onIf-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 fromGET /_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
Hostnames 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
connectit was going to render anyway: on a match it sends aWelcomeand 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 asconnectparams —?for=1042is 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: trueemitsOp::Focus, the op the protocol has had all along and nothing was sending.autofocuscannot 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. Likescroll_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. transitiongains"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_metertakeslabelsso 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
--devthe 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--devbefore, 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-confirmis not a reserved attribute, and the nav script took it over in 2.0.3 — rightly, since it replaced anonclick="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 ownsubmithandler ever ran. That is not a matter of taste:window.confirmblocks 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 cancelablesoli:confirmon the element before the native box: a page that handles confirmation itself callspreventDefault()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 announcedread 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,promptandbeforeunloadblock 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, sosoli test --browsersimply 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 — aconfirmguarding a deletion must not be answered “yes” by a driver — and it is recorded as a page error, soassert_no_page_errorsshows 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 servecost two round-trips perpoll_msforever — 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_jobsand_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.
levelarrived 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 haslevelwired, and gets an application that works and a meter that does not move. The catalogue gainsvu_meterand thevu_strip/vu_segment/vu_scale/vu_zoneit is built from, plus ascenebuilder for the node kind that landed in 2.3.2.
Web
- The image-transform ceiling is configurable.
w,h,thumb,squareandcropwere 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_DIMENSIONmakes 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 --browserworks. 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 namedbrowser, so they have to live intests/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 --euito a window — handler, view, motion, a local handler, then assets, capabilities, scenes andeui_stats.
v2.3.4 — 2026-09-15
Dev tools
soli testcounts 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 eachtest(...)block is counted as it ends, which nothing did before. The bar reads41/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, then1 204 tests, then6 018 assertions.- New lint rule
idiom/prefer-to-s.x ?? ""means “render this, and render nothing when it is nil”, which is what.to_ssays 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 butauditrefused 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
Welcomeframe 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 answersmin(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. scenejoins 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. Itsshaderandmeshtravel the verified asset path a picture'ssrcalready 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.uniformsis 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 advertisesprotocol_min: 2and 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> --euino longer writes a slider that can end the session. The catalogue gated the track'spointer_movehandler 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.sliderandrange_slidernow declare atrackand onechangehandler 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 understandstrack.- A light-dismissable dropdown, and a manifest that says what a bare origin opens.
dropdowntakes anon_close, and a press outside the widget reaches the overlay asblurwithout 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, andbackbecomes 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— andanimationbecomes 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
imagesrc was a path underpublic/orapp/assets/, which an attachment is not.eui_asset(bytes)puts bytes in the content-addressed store and answers{"asset": "<hash>"}, which asrcnow accepts beside a path;read_uploadreads an attachment back whatever service holds it, and afile_uploadevent carries acontent_typederived 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 forsoli 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
ifstopped at the first newline, so]) if condat 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 itsifto the scan.
Tooling
soli teststops 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 setsSOLI_ARGON2_FAST=1for 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_verifyreads 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. Only1ortrueswitch it on.
Web
<% unless %>is a block in a template. The template parser knew five block openers —if,for,content_for,form_withand a component — and anything else went to the language parser as a statement complete in its own tag. Since a block-formunlessthere does not insist on itsend,<% unless c %>parsed as an empty one, silently: the body was dropped and the template's own<% end %>then failed withUnexpected 'end' outside of block, naming a line two below the mistake. It is now a real block wherever anifalready was — top level, inside a loop, a branch, acontent_for, aform_withor a component — withelsebut notelsif, 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_eqand the factory and mock helpers resolved inside a rendered.html.slvin production — names ordinary enough thatregister_builtinsalready refuses them everywhere else in serve mode. A template that called one was relying on an oversight; nothing in the framework did.soli testis unaffected. - A worker thread builds the builtins once, not three times. The ~500 bindings
register_builtinsdefines 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 atSOLI_WORKERS=16put 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.shmeasures what asoli serveprocess actually costs.ps -o rss=cannot separate a process’s own heap from the file-backed pages everysoliprocess shares, so it readsPss_AnonplusSwapPss— 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--abalternates 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_assertionsoff 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 answerscall 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 thedefaultdatabase — the fallbacks used whenSOLIDB_HOSTandSOLIDB_DATABASEare 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/euiredirects 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, andeui_wake,scroll_toandposition: "pointer". The catalogue went from 79 entries to 175 — every builder ineui_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 ownwakeclock 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: aBatchis 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.5join 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 withEUI: unknown colour role 'series.1'. scroll_toon 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.locationandnfc_tagevents. 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 fmtis idempotent again on a postfix guard whose value wraps. Formattingreturn text(glyph, { ... }) if name.nil?broke the value across lines, which stranded the trailingifwhere the next pass could not see it — so a secondsoli fmtrewrote the guard into a blockif. The formatter now emits the block form on the first pass, matching the rule it already applied when creating a guard, sofmt(fmt(x)) == fmt(x). A short guard still stays postfix.SOLI_TEST_SERVER_DEV=1gives a spec run a dev-mode server.soli teststarts 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, whichserveonly 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#showended with"body": Base64.decode(b64), andBase64.decodeon 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 overrodeAttachmentsController#showand copied the old shape, switch tobody_base64too. - 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_BYTESbounds the sum of request bodies buffered at once.SOLI_MAX_BODY_SIZEonly 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 answers503withRetry-After. Defaults to 16× the per-request cap (128 MiB);0disables it. See Hardening.
v2.2.0 — 2026-09-11
ORM / database
- Batch iteration:
find_eachandin_batcheswalk 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.eachwas no escape hatch: it materialises first, then iterates. Paging is by key (FILTER doc._key > <last key seen>, sorted by_key), notLIMIT 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, andfind_in_batchesis 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 insidegrouped(...). A dropped clause in a correction run is a wrong run that still reports success. batch_sizedefaults 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_localerendered in whatever language the previous request on that worker had asked for.- It was a write-side bug too.
Model#saveand#updatestore 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
localevalue in the session, else alocalecookie, else the best match forAccept-Languageamong the locales you actually ship (fr-CAis served by afryou have), elseSOLI_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.
- It was a write-side bug too.
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_moreand a cursor id when more remain; the ORM's read path took the first batch and never followed the cursor. EveryModel.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 withhas_more: false: the same query gave 5000 rows warm and 1000 cold. The cursor is now drained to completion, as thedb.*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 correctfr.ymltherefore asked foritems_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
_fewor_manyat 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/otherfor the rest. A_zerokey is still honoured for a count of 0 in any language, and a category your file omits falls back to_otherin the same locale before another locale is consulted.
- French has no zero category: 0 and 1 are both
t()translates. It returned its own argument —t("welcome.title")rendered the literalwelcome.title— while the views documentation gives it as the way to translate; onlyI18n.translateever 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_LOCALEandI18n.set_default_locale(...)now set it; it remainsenby 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 teststarted 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
--devthere: hot reload watches files nobody edits in a process that lives one run, and the per-request dev instrumentation (the AQL log behinddev_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.
- A handler the VM refuses passed the whole suite, and
- 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-Cleftsoli servechildren adopted by init — three were found alive six hours later, still holding database connections and re-creating their_testdatabases the moment anything dropped them. The kernel is now asked for the guarantee instead.
Added
soli new <app> --euistarts an application with a native window as well as a web page. It writes the EUI widget catalogue asapp/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 therouter_euiline inconfig/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 theeuifeature, which could not serve what it wrote.
I18n.set_default_locale(locale)andI18n.default_locale(), with theSOLI_DEFAULT_LOCALEenvironment variable. See Which locale a request runs in.
- Clauses keyset paging cannot serve are refused, not silently ignored:
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 — anoverlaypinned 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--devand before the first render, so a view can composedev_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_TRACEand 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.
- Nothing the window knows is in it. Frame time, quads, memory — the client prints those under
Build
- EUI is now part of the default feature set. v2.1.0 shipped it behind
--features euiand said the default build contained none of it; that is no longer true. A stockcargo build --releaseorcargo install --path . --lockednow hasrouter_eui,eui_capabilitiesand the/_eui/session/<component>socket, so an app whoseroutes.slcallsrouter_euino longer fails to boot withUndefined variable 'router_eui'on a binary installed the ordinary way.fullgains it too, so it stays a superset of the default set.- Still off:
eui-desktop— the winit/wgpu window thatsoli desktop build --euineeds. Servers do not link it. - Drop EUI again with
--no-default-featuresplus the features you want; see Slim binary.
Auth & security
- An EUI component is only reachable over its own socket.
router_euiregisters 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 aparams/propshash 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. connectcan refuse a client. No middleware runs for a WebSocket upgrade, and there was no way to say no: whateverconnectreturned, the view was rendered and sent. A handler may now return{"close": reason}fromconnector any later event — the client gets anErrorframe (403) with the reason and the session ends — androuter_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_CONNECTIONSand 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, aResynctwenty at a time. A client'sErrormessage reaches the log with control bytes stripped, andEUI_TRACEprints session state only under--dev. - The
Welcomeframe carries a session handle, not the cookie. It named the session by the first sixteen bytes of the raw session id — the valueHttpOnlykeeps from page scripts, and the very thing the LiveView socket stopped sending. It is a SHA-256 handle now. - An image
srcis confined topublic/andapp/assets/. Any file under the app root could be read, hashed and served to anyone with the hash — the publisher key and.envincluded — whenever a view built asrcfrom 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_unwindHTTP 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_GRACElike 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.
Errorframes 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.rsrenders 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
.gitignorenames 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
returnor a jump, and the client refuses the whole handler otherwise. The compiler appended thatreturnunless the code already ended in one — and it decided by looking at the last byte. Aset_styleends in its style-table id as a varint, so the ids 32 and 64 are the bytes0x20and0x40: the operand read as thejumpandreturnopcodes, 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 = @litfor both dangerous ids and asserts thereturnis there.
- Why it moved around. A chunk must end in
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.slrather 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 thecounter-appexample, meant to be copied into an application and edited. See EUI — the widget catalogue.
Added
- EUI sessions, behind a cargo feature.
cargo build --features euiaddsrouter_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 pointerpayloadis local to the node holding the handler, not the leaf under the pointer. - A native window.
soli desktop build --eui <component>(featureeui-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
videonode plays a GIF or an animated WebP, named like an image, withplaying,loopandpositionprops and anendedevent. Decoded in the sandboxed worker; the window wakes exactly when the next frame is due. - Sound. An
audionode names a sound like an image names a picture, withplaying,volume,loopandpositionprops andended/time_updateevents. Only the window opens a device. - The viewport reaches the application.
params["viewport"]withconnectand aviewportevent on change: responsive views. A desktop window closes cleanly on Ctrl+C. - Windowed lists. A
listwithcount,heightsand awindowhandler 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).
--workersandSOLI_WORKERSstill override. --no-dband--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()andtheme.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/euiis signed with an Ed25519 key generated on first use intoconfig/eui_publisher.pkcs8— keep it, clients pin it, never commit it.eui_capabilities(…)inroutes.slsays what the app asks for. - Canvas paths. A
canvasnode'spathsprop 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 inrouter.rs, and the newsrc/serve/eui/module.live/,template/,vm/andinterpreter/are unchanged.
v2.0.7 — 2026-09-06
Fixes
.whereintermittently 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 asparams; dispatch replaces that global, the hash is freed, and its address stays behind. A laterModel.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.
- Why it looked random. The guard that stops
v2.0.6 — 2026-09-04
ORM / database
- Fixed: a
Model.where(...)chain inside atry/catchfailed on the server, withCannot access property 'limit' on QueryBuilder(or'first','order', …). The VM — the enginesoli serveuses 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,
servedemotes the handler to the interpreter, and the chain works — which is why the same code ran fine in most routes. A handler with its owntry/catcharound the model call swallowed it and reported its own failure, soservenever learned it had to demote and that route stayed broken for every request. - The VM now hands query-builder access back through the same
EngineFallbackroute class reflection and dynamic finders use. That refusal is deliberately not catchable by user code, so it always reachesserve; the handler demotes once and is then blacklisted, costing one re-run rather than one per request. Workarounds of the shape.limit(1).allwritten to dodge this can go back to.first.
- Why it bit in one place rather than everywhere. The old error was catchable by application code. Normally it escapes,
.first(n)on a query builder returns the firstnrecords 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.firststill returns a single record ornull. Refused on aggregate andexistsqueries, 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 existingsoli_versionfield gains an exact form —soli_version = "=2.0.3"— andsolirun inside that project switches to soli 2.0.3, fetching and verifying it the first time and caching it under~/.cache/soli/runtimes/. The plainsoli_version = "2.0.3"form is unchanged and still means "at least". Same idea as.nvmrcor 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 whichreports which version will run here, from which manifest, and whether it has been downloaded yet.SOLI_NO_PIN=1skips 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,--versionand--help.soli updateespecially — 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.tomlis author-controlled content in any repository you clone. - A pinned fetch refuses a release that publishes no checksum, where
soli build --targetonly 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.
v2.0.5 — 2026-09-03
Fixes
has_manyon an unsaved owner no longer hits the database. Its builder can never match a row, soupdate_all,delete_all,countandexists?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,solidbandsolikvdrivers 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 defaultexistsprobed 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.
--devrefuses to start whenAPP_ENVisproduction. It used to skip every production boot check silently — noSOLI_APP_HOSTS, no session-secret floor, security headers off, the/__solidevdiagnostics exposed — with nothing in the output to say so. Drop the flag, or setAPP_ENVto something else.enable_trust_proxyis off by default in new apps. AnX-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 newSOLI_TRUSTED_PROXIES(IPs or CIDRs, comma-separated) so the headers are honoured only for requests that really came from them.cors()refusescredentials: truewith a wildcard origin. Browsers rejectAllow-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 asIN— the same shape a JSON body can send. A client posting{"token": {"ne": null}}turned an equality check on a secret into!= nulland 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.updatenever persist_key,_id,_rev,_from,_toor an STItypefrom their input, with or withoutattr_accessible. And the staticModel.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")inconfig/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_assignskeys the server rendered assoli-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
GETfrom an email, so a link scanner or a forwarded message received the session. /_metricsis no longer public. WithSOLI_METRICS_TOKENset 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
headersin the options hash of everyHTTP.*verb.HTTP.get(url, {"headers": {"Authorization": "Bearer " + token}})now sends the header — as dopost,put,patch,delete,head, the*_json/get_jsonpvariants, andget_all/get_all_jsonfor every URL in the batch. That is the shape the docs already showed, but the runtime read onlytimeoutfrom the hash and dropped the headers on the floor, so an authenticated call went out anonymous unless you fell back toHTTP.request.- Yours replaces the default. A
Content-Typeon the body verbs or anAccepton 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
nullvalue 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-LengthandTransfer-Encodingare 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 nestedheaderskey, so the one options shape works everywhere.
- Yours replaces the default. A
- The static checker knows the whole
HTTPclass. A top-level script,soli checkandsoli -etype-check before running, and the typedHTTPdeclaration was a partial list with no options parameter — soHTTP.get(url, {"timeout": 5})failed withWrong number of arguments: expected 1, got 2andHTTP.get_json(…)withCannot 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 ofcannot index Future<String>.
v2.0.2 — 2026-09-02
ORM / database
db.timeout(secs)anddb.query(sdbql, binds, {timeout})raise the 10s ceiling on raw SDBQL. The QueryBuilder form already gave one Model read more than ten seconds; aSolidbclient had no equivalent, so a heavydb.querydied 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 argumentsfor 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 asIntorFloat; a zero, negative, non-numeric value or an unknown option key raises.- A read
db.queryinsidegroupedjoins the batch when the client targets the same host and database as the ORM, so the largest.timeoutany member asked for covers it. A write viadb.querystill runs immediately.
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-aggregategroup_bydied withError: 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 theModel.timeout(secs)static entry point.- Chainable and position-independent like
.limit. Seconds asIntorFloat, 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
groupedblock runs under its most patient member. Coalesced reads are a single request, so the batch takes the largest.timeoutany 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.
- Chainable and position-independent like
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/:nameno longer exists, andSOLI_JOBS_CALLBACK_URL,SOLI_JOBS_SECRETandSOLI_JOBS_DATABASEare 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_SECRETchanged meaning. It now signs outgoingWebhook.*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 = trueis 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
NoTlsand themysqlcrate resolved with no TLS backend — so?sslmode=requirewas 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 theringprovider, 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:requireencrypts but does not check who answered, and verification starts atverify-ca— so a URL behaves exactly as it does underpsql, and a self-signed server keeps working. preferis 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=disableasks 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, sincetokio_postgresrejectssslrootcertoutright andmysqlrejects 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
localhostURL — which would have satisfiedREQUIREDwith a cleartext connection.REQUIREDand up now take TCP. Postgres never negotiates TLS on a socket either, sorequirefails 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 TLSimmediately.
- libpq's ladder, and libpq's semantics.
- The SQL adapters are a CI gate. Their tests skip when no server answers, and a skipped test still reports
ok—cargo testswallows 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, andSOLI_REQUIRE_DB=1turns every would-be skip into a failure. Locally, without the flag, the suite skips exactly as before.
- SVG images are embedded as vectors. A template
imagewhose 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 usesfont_dirs. - An embedded SVG produced an unreadable PDF. The imported Form's own resources — the ICCBased colour space svg2pdf always attaches, plus a
FontFile2for<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 withsoli lint+soli test). First frozen runners:claude -p, OpenCode + DeepSeek, Grok Build. The /ai table stays empty until a paid run is committed towww/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. VerifyStripe-Signatureonreq["body"], fulfill once, treat the success URL as display only.
ORM / database
- Migrations auto-load models.
soli db:migrateloadsapp/modelsandapp/servicesbefore each migration (recursive, same order assoli serveanddb:seed). Data migrations can callUser.create(...)or iterateUser.all()without animport. Engine migrations load models from the engine root the same way.
Language
- Sub-expression comprehensions and binding
matchcompile on the VM. Nested[x for x in xs]andout.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() { … })andModel.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
groupeddeferred 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=1stops 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 athrowor a 404RecordNotFound— and exits the process rather than panicking, which the per-requestcatch_unwindwould have turned into a 500.SOLI_ENGINE_LOG=1still logs every demotion, one line per unique handler. The bytecode VM only runs outside--dev, so neither applies tosoli serve --devorsoli test.- Backticks compile on the VM.
`printf hello`lowers toSystem.shell, and reading a field on aFuture(or agroupeddeferred 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 asthis. 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.updaterun on the VM. Before/after callbacks use the same temp-instance wrap as the tree-walker; afalseveto returns an instance with_errors.- Class
method_missingand state-machine members run on the VM.UserMailer.welcome(user)no longer demotes the handler, andorder.pay/paid?/can_pay?dispatch on the bytecode path for a machine with noguardand no transition hooks. A machine declaring any of those falls back — same captured-scope reason, and checked before the state is written. record.delete()withdependent:or attachments stays on the VM. Child cascades anddetach_all_uploadsrun 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 barePost.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 anafter_*callback with no matchingbefore_*one silently skipped it in the tree-walker — so it ran in production but not under--devorsoli test. Both engines now run it, matching the documented callback table.- Class
method_missingno longer shadows reflection and dynamic finders. On a class defining a staticmethod_missing(theMailershape),Foo.send("bar"),Foo.methods()andUser.find_by_email("x")dispatched intomethod_missingon 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/deleteon 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
groupeddeferred result materialises when read into a container. The property fast paths pushed the placeholder straight onto the stack, sorender("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 ado … endblock — so that call committed a real transaction in production and ran untransacted under--dev. A bare identifier is now recognised too.define_methodandalias_methodnow 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.includeof 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.includeandextendnow use the same resolution as any other name read.- A method's implicit return produced
nullunder--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— returnednullin 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] = vas a function's last statement returned the whole hash (andh["k"] = vreturnednull) instead ofv. All four hash-set opcodes now yield the assigned value, matching the tree-walker. - Nested-index fusions dropped a stack slot. The
AddNestedIndex/SetNestedIndexpeepholes treated the trailingPopas optional, but they push nothing where the sequence they replace leaves a value — so a function ending intotal = total + h[ks[k]]orh[ks[k]] = vreturned a stray local. All four now require thePop, as the olderIncrLocalpeephole already did. includeon a non-Modelclass failed the type checker. Nothing in the checker read a class'sincludes, so the class type carried only its own methods andsoli checkrejectedu.greet()forclass User { include Greetable }.Modelsubclasses escaped only because their members resolve asAny. Module members (including a module's own transitive includes, andclass_methods doblocks) 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"} }resolvedlabeltoBase's. Own methods are copied first now. - A nested concern's
included donever ran against the class. Hooks fired only for the modules named in theinclude, so withmodule Auditable { include Timestamps }andclass Post { include Auditable },Timestamps's hook was registered againstAuditableand silently never applied toPost. 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--devand 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 dowas 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 … endis a mixin (and a namespace).include/extendmix methods in.included do/extended doreplay class-body DSL on the host (so a concern canvalidates/has_many).class_methods doinstalls class methods on the includer;def self.included(base)is called with the host class. See Modules. - Block
unless … end.unlessis a first-class statement, not a rewrittenif !cond. Multi-line membership guards parse and stayunlessthroughsoli fmt(a short body still becomes postfixexpr unless cond).elseis allowed;elsifis not. Postfixexpr unless condis 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 servefails closed withoutSOLI_APP_HOSTSand a 32+ characterSOLI_SESSION_SECRET. WhenAPP_ENVisproductionorprod, boot refuses to start if the public-hostname list is missing/empty or the session secret is missing/short; the error names the variable.--devand non-production env still boot without them. See Production security defaults. soli newrequires per-form CSRF tokens. The generated.envsetsSOLI_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-assignmentlint.Model.create(params)/.update/.create_manyin controllers or services with the raw request hash is a warning;permit/_permit_paramsis clean.- File-mode HTTP responses no longer unwrap a poisoned builder.
soli serveon a plain directory built redirects and bodies with.body(..).unwrap(), so a path that put CR/LF inLocationpanics the worker. Those sites usefinish_responseand 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, andPOST /__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/jobskeeps the Origin/Referer gate now, which its own same-origin forms pass. It stays out of the mandatory-token layer thatSOLI_CSRF_TOKENS=requireturns 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_COOKIESwas bypassed by the two-argumentset_cookie. That form hardcoded its attribute string and skipped the builder that applies the flag, soset_cookie("remember_me", token)shipped a credential with noSecureeven 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, souser: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_manystoredencryptsfields as plaintext on SQL. The bulk-insert branch bypassed the write layer that applies the transform, so a model declaringencrypts("ssn")persisted raw values throughModel.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/htmlby default.has_one_attached/has_many_attachedtreated an absentcontent_typesas "anything", and the blob route echoed the client-declared type with noX-Content-Type-Options— so a barehas_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 (notext/html, noimage/svg+xml, no XML), and the blob route sendsnosniffplusContent-Disposition: attachmentfor 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 incontent_types. - A locked account could never lock again. In the generated auth stack,
register_failed_attemptreturned early wheneverlocked_atwas set, and onlylocked?()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 askslocked?(), 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 toHTTP.get/HTTP.post, which read that hash fortimeoutonly and returned the body as a String — so theAuthorizationheader was dropped andresponse["body"]was a type error. They now useHTTP.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 withcode_challenge_method=plain— identical strings, so it added no protection. It now sends a realS256challenge,Base64.urlsafe_encode(Hex.decode(Crypto.sha256(verifier))). live_componentinterpolated its child id into HTML unescaped. The id comes from application data — the keyed-child pattern islive_component("row", {"id": row.slug})— and reached the DOM throughmorph→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/uploadis reachable without a session, because a first-time visitor may not have one yet. One unauthenticated client could therefore mint a freshX-Soli-Upload-Idper 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: aPOST /_internal/wipeor/_admin/userssilently lost both checks. The exemption is now the endpoints the framework actually serves —/_health,/_ready,/_metrics,/__coverage__, and the reserved/__soli/,/__solidev/,/__dev/,/__livereloadprefixes. An application route that wants out still says so withskip_csrf. SOLI_FORCE_SECURE_COOKIEScovers the whole cookie jar. It only ever addedSecureto the framework'ssession_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 withoutSecureon 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 addsSecureon 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 authreturned 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
429over the limit — one shared budget across the three (AUTH_ATTEMPTS_PER_IPperAUTH_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 whenenable_trust_proxy()is on, so a rotatingX-Forwarded-Forcannot 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_errornow 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_urlreadsAPP_BASE_URL. It was a hardcodedhttp://localhost:5011behind aTODO, 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_iprejected its own documented third argument. It was registered with an exact arity of 2 while its body readargs.get(2)for the window, so the documentedrate_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 warningsruns 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:componentpair 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_idin 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 noCookieminted a uniquesess-id per tab, so two windows of the Field Desk looked like different sessions. Putdata-live-room="name"on the mount; the client sends?room=nameand every socket joinsroom:name:component. The desk tutorial usesfield-desk. - Chunked uploads and
send_update. Files over 256 KiB POST to/live/uploadin chunks.send_update("score", { "score": 5 })writes_componentsand, whenrouter_live("score")exists, runs the child withevent == "update"(Soli'supdate/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_unwindfault 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 …, whichtry/catchintercepts), 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 thepatchhelper's body with nothing in it — and a helper registered variadic (nothing upstream checks the count) readsargs[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 nightlytemplate_parse_renderfuzz 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_LEVELdefault, 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 whatMoney.format(m)displays; currency codes are case-insensitive and validated. - The new classes type-check.
Money,Url,Logger,Toml,Yaml,CircuitBreaker,SemaphoreandRetrywere 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. Retryworks in views and helpers. It was registered only in the interpreter constructors, soRetry.with_backoff(...)in a.html.slvview or anapp/helpers/*.slraised Undefined variable: Retry while every other new class resolved there.Retry.withinbacks off. It ignoredfactorandmax_delayand retried at a constant 0.25s, so a longdeadlinemeant 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.configureaccepts fractionalreset_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 everytry_acquirefor 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.withinstops 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 defaultmax_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.newleft{"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 behindreq["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-requestcatch_unwindnever 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_successclosed 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.releasewas 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.parseno longer leaks serde’s datetime marker. A TOML date came back as{"$__toml_private_datetime": "…"}instead of the timestamp, soconfig["when"]was a one-key hash — and dates are everywhere in real TOML.Url.buildstops losing data. Aqueryhash silently dropped arrays and nested hashes (a filter URL lost its filters); they now expand to the bracket names request params use.username/passwordare honoured, soUrl.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.DateTimeanswersis_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.Urldecoding matches request params.+decodes to a space (soUrl.paramsandreq["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_paramnow 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
DateTimeanswers the universal members.inspect,to_s,class,nil?,blank?andpresent?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 forDateTime.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
DateTimecomponent accessors agree on timezone.hourandminuteused to return UTC whileyear/day/formatused 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. Callt.utc()for UTC components on the same instant, ort.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.humanizeis magnitude-only. It never appendsago(including for negative intervals fromDuration.between). Usetime_agofor 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. Soupdate({"prefs": {"theme": "dark"}})destroyed the rest ofprefson Postgres and merged into it elsewhere, andupdate({"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_allfollows 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_existshardcodedtable_schema = 'public'while every other schema query resolves throughcurrent_schema(), so with a differentsearch_pathreads returned empty and the job poller never claimed — both silently. A record created and then immediately looked up raisedRecordNotFound. - 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_donescanned a 500-row window, anddeadrows 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.everyregistered schedules that never fired."90 minutes","25 hours"and"40 days"each emitted a*/Nbeyond 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 as42, and"01"and"1"collapsed into one bucket. Only aggregates are parsed now, on both the column and document paths. - Column-mode
group_bydroppedORDER BY,LIMITandOFFSET. Its document-mode twin honours all three, so the same grouped query returned unordered, unbounded rows on atable "…"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. LIKEon a non-text column was rejected by the database. The pattern's placeholder was cast to the column's type, producing$1::text::uuid— anduuid LIKE uuidis 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 downcould 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.executeself-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
errorframe did not, so asoli-disable-withbutton stayed disabled with no way to retry. Model.countreturned an error string instead of raising. On atable "…"model whose table was missing, the failure came back as"Error: …"where a number belongs — sotry { Model.count() } catchnever fired and capability probes concluded the table was there. It raises now, and atabledeclaration on a connection that cannot serve it is refused instead of silently falling back to document storage.module Foocould not be typed into the REPL.modulewas 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:loaddid not read.env. Unlikedb:createanddb:migrate, so aDATABASE_URLliving there was invisible and both reported "needs a SQL connection".- The eval harness was missing from a clean clone. An unanchored
tasks*rule in.gitignorealso matchedevals/tasks/, so all twelve task fixtures were untracked andscripts/evals/run.pyraisedFileNotFoundError. The rule is anchored to the repository root now. soli db:dropcould silently drop the default database. Its flag loop ignored anything it did not recognize, unlikedb:migrate, sosoli db:drop --connectionwith the value forgotten fell through with no connection and dropped the default — unrecoverably, without a word.db:create,db:drop,db:schema:dumpanddb:schema:loadnow 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--connectionwas 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_connectionrestored 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 aDropguard now. - A numeric
INlist defeated every sibling predicate. It compiled to a chain of disjuncts with no parentheses, and the caller joins siblings withAND, which binds tighter — soPost.where({"id": [1,2,3], "status": "open"})becameid=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
.wherechained onto a hash.wherewas 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.wherealso 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,countanddelete_allignored a model's ownconnection. They tested the ambient default rather than the model's, so a model withconnection "reporting"in a SoliDB-default app sent its reads to SoliDB while.where(…).allon the same model correctly reached Postgres.delete_allwas worst: it listed keys from one database and routed the deletes to the other. All four are connection-aware now, as iscreate_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 fmtemitted lines thatsoli lintrejected. The width estimate used when collapsing a single-statementifto 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.tomlwere 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-featuresbuild 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 madecreate/savefail 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 likesqlite column insert row: UNIQUE constraint failed: orders.codein_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
Displayis that literal string, so every message was our context plus those two words. Errors now carry the driver's real message andDETAIL— which is also where the offending column name comes from. - Concurrent
increment/decrementand 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, andSET 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 orNULLcolumn 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.executeand the column-table helpers were registered on every interpreter, so a controller or template could run arbitrary SQL and leaveSET/ATTACH/PRAGMAon a pooled connection. They are registered only when running a migration.db.executeuses a dedicated connection (and resets the session afterwards onsqlite::memory:). - MySQL
DEFAULTstrings are escaped for MySQL. Quote-doubling alone let a default ofx\', extra INT --close the literal and become a second column. Backslash, quote, NUL, newline, CR, and SUB are now escaped the waymysql_real_escape_stringdoes. - 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.
DateTimevalues are nanoseconds throughout the runtime, but the column-mode reader wrapped the seconds value the parser returns. Adatetimecolumn 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. createandsaveon 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 columnDEFAULT, a database-side trigger, and the stampedcreated_at/updated_atstayed 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, andconnectionwas missing from that list, so a model declaring it failed to load with "expected ':' and type annotation for field declaration". Bothconnectionand the newtableare registered now.
ORM / database
- Column-mode
encryptsand STI. Atable "…"model can encrypt text columns (AES-256-GCM, same as the document path) and share that table across subclasses with a stringtypediscriminator. Subclass queries addtype IN (class, descendants);find/find_byon 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 notypecolumn. Composite primary keys are still refused. - First-class attachments.
has_one_attached("avatar")/has_many_attached("photos")default to disk (./storage/attachments) orservice: "s3"/"solidb". Sameattach_/detach_/_urlmethods asuploader.deletepurges blobs. A LiveViewsoli-uploadhash attaches as-is. - Batched HABTM
.includeson Postgres and MySQL. Ahas_and_belongs_to_manyeager 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_countuses the same path, so it counts join rows for HABTM.through:includes,.havingand.joinlanded 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 trailingmessage, 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.slwas never a valid invocation (it issoli file.sl) and appeared in ten places; the live-reload page documented aSOLI_ENVvariable nothing reads and a./dev.shthe template never shipped; the error-pages page documented a--no-devflag 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 theMathnamespace 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/lintingand/docs/core-concepts/testingall fell through to the catch-all route. Every internal/docslink in the site now resolves, and docs search no longer offers entries for API that does not exist (twelveMath.*functions and two pointing at a removed page). - The SQL adapter environment variables reached the Configuration page.
SOLI_DB_ADAPTER,DATABASE_URLandSOLI_DB_POOL_SIZEwere 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 alongsidegt/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_componentassigns,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
.wherecomparisons,IN,LIKE, andOR. 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
.includeson 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 theINquery on column-aware models. A raw string filter on.includesstays SoliDB-only. soli db:schema:dump/soli db:schema:load. Dump writesdb/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. Atable "…"model can eager-loadhas_and_belongs_to_manyandhas_many through:(the join / intermediate table must also be column-aware), filter parents with.join("comments")(correlatedEXISTS), and filter groups with.having("n > 5"). through:eager loading on SQL..includeson ahas_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 outthrough: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 correlatedEXISTSrather than a real join, so a parent with two matching children is returned once, not twice, and theSELECT docshape is untouched. A child-side filter rides inside the subquery in the portable hash shape..having("n > 5")compiles to aHAVINGclause. 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. Theraw_sqlcapability 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 singledoccolumn hydrates as documents; any other shape becomes a hash per row. Raises on SoliDB, pointing atModel.querywith SDBQL.create_manyis 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-itemattr_accessiblefilter still runs on every row: bulk insert would otherwise be a perfect mass-assignment bypass.pluck/selectpush 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 TABLEno 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 forbelongs_to,has_many,has_oneandincludes_count— one query per association whatever the parent count, usingcolumn IN (…)over the real foreign-key columns. Measured in a live app: 3 parents withincludes("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_byand bulk writes on real columns. Grouping with sum/avg/min/max/count (a non-numeric aggregate is refused by name), plusdelete_all/update_all, which stampupdated_atwhen the table has it and never rewrite the primary key.soft_deleteworks when the table has adeleted_atcolumn — the scope becomes an ordinaryIS NULL/IS NOT NULLfilter. 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_oneandsoli test --fail-on-n1could 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 aDbspan so the flamegraph shows database time. Verified in a live--devapp: the badge reads5q, 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 pointingDATABASE_URLat a database nobody created failed at boot with a driver error.db:createrunsCREATE DATABASE(through thepostgresmaintenance database on Postgres, a db-less connection on MySQL) or creates the SQLite file and its parent directory;db:dropremoves it, including the-wal/-shmsidecars on SQLite, which would otherwise resurrect committed data into the next file of the same name. Both accept--connection NAME.
SQL performance
indexdeclarations 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. Nowindex "status"creates an expression index on the JSON field:((doc->>'status'))on Postgres,((doc ->> '$.status'))on SQLite, and on MySQL a generatedSTOREDcolumn plus an index on it, because MySQL cannot index a JSON extract directly. Multi-field andunique: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 comparesjsonbnumerically (10matches a stored10.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_atandpriority(plusnext_run_at/enabledfor 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 }). Andfulltext/bloom/cuckooindex types plusvector_index/geo_indexare 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+docdocument tables, so a greenfield column schema had to be built with psql, thesqlite3CLI, or another framework's tooling. Nowdb.create_table("orders", { "id": "pk", "amount": "decimal(10,2)", "timestamps": true })declares real columns, withadd_column,drop_column,rename_column,rename_table,add_index,drop_index, andexecute(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 inlineREFERENCESand then ignores it, so foreign keys go at table level there; MySQL takes noIF NOT EXISTSonCREATE INDEXand needs the table onDROP INDEX; SQLite cannot add aUNIQUEorNOT 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 andsoli db:migrate upplaces it correctly — no--connectionflag, and no chance of running it against the wrong schema. A line inside a string or afterdefis ignored; a second declaration is an error. Each connection tracks its own versions.--connection NAMEis 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=sqlitewithDATABASE_URL=sqlite://db/app.sqlite3(oradapter = "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 nolibsqlite3. See SQLite specifics. - Column-aware models work on SQLite too.
table "orders"maps a model onto an existing SQLite schema's real columns, introspected withPRAGMA 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 aDATETIMEholding a unix timestamp (seconds or milliseconds) comes back as a date, exactly like stored text does. AnINTEGER PRIMARY KEYis recognised as database-generated (it aliases the rowid, with or withoutAUTOINCREMENT); a key of any other type must be supplied. - Background jobs and cron run on SQLite. Postgres claims due work with
SKIP LOCKEDand 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 IMMEDIATEso 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 — aDECIMALcolumn has NUMERIC affinity, so SQLite stores the value as aREALand19.90reads back as19.9(Postgres and MySQL keep the scale; declare the columnTEXTif the exact text matters). - Feature flag.
sqliteis on by default and included in thesqlalias; 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 withtable "orders"and read/write its real columns. See Column-aware models. - Schema by introspection. At boot Soli reads
information_schemaonce 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_atare 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, andModel.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, orsolidbconnection 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 (stringtypecolumn) later joined the supported set; composite primary keys are still refused. Doc-store models on the same connection are untouched.
Testing
soli testdrops 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*_specdatabase 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=1restores the truncate behaviour when the tight test loop matters more than the leftovers;SOLI_TEST_FRESH_DB=1is only meaningful in combination with it now.
Background jobs
- Jobs now run inside the Soli process. The queue is a
_jobscollection 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 (PostgresFOR UPDATE SKIP LOCKED, MySQL a token claim, SQLite the database write lock, SolidB anIf-Matchcompare-and-swap), so severalsoli serveprocesses 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.
attemptsincrements at claim time, and arunningrow whose lease expires is reclaimed — which is how work survives a killed process. Completed rows are pruned afterSOLI_JOBS_RETENTION_SECS; failed and dead rows are kept for inspection. - Cron is evaluated and fired by Soli.
Cron.schedule/list/update/delete, theCron.every/hourly/daily_at/weekly_atbuilders, andstatic cronall keep working; a compare-and-swap on each schedule'snext_run_atmeans 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 sameX-Webhook-Signature/X-Webhook-Event/X-Webhook-Deliveryheaders 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 newnow also createsapp/jobs/. - Standalone worker and queue dashboard.
soli jobs(aliassoli worker) loads the app, claims_jobs, and runs them with no HTTP listener — pair withSOLI_JOB_WORKERS=0onsoli serveto scale workers separately.soli jobs list/retry/cancelinspect the queue. /__soli/jobs is the same cancel/retry UI in--devand, in production, when you setSOLI_JOBS_USER+SOLI_JOBS_PASSWORD(HTTP Basic) orSOLI_JOBS_TOKEN(Bearer). Unconfigured production 404s.Job.retry(id)re-queues a failed or dead row and keepsattempts/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
runningand nothing ever reclaimed it, the exact opposite of the intended "let the claim expire and retry". The claim is now released properly. --devpolls the queue every 5s instead of every second.soli newscaffoldsapp/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 settingSOLI_JOBS_POLL_MSoverrides the dev pacing too.- Retention and the queue filter no longer scan the whole table. Pruning counted the doomed rows with a
SELECTbefore deleting them, materialising every row's full document just to report a number theDELETEalready returns; and the dashboard's queue list selected one document per non-terminal job to collect a handful of distinct names, where aGROUP BYdoes. 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=postgresormysqlwithDATABASE_URLruns Model CRUD against JSON document tables: create/find/update/delete, hash-style.where, order/limit/count/exists, aggregates, bulk writes, soft-delete,pluck, andModel.all. Phase 3: eager.includesbatching (belongs_to/has_many/has_one, and HABTM — see above), multi-rowgroup_by, andsoli db:import(SoliDB → SQL). Graph and pgvector stay SoliDB-only (through:includes and.havingarrived later in this cycle). Seedocs/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. Runup/down/statusagainst a named connection fromconfig/database.toml(SQL secondaries).- A nested-object
updateinside 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 rawBEGIN/COMMIT— on the very connection the enclosingModel.transactionwas holding. Sotransaction(fn() { user.update({"profile": {"theme": "dark"}}); other.save(); raise "boom" })committed the earlier writes and the rollback did nothing; on the error side itsROLLBACKdiscarded writes the caller had already made. It nests with aSAVEPOINTnow, 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 BYbinds to that output column — soOrder.group_by("status").order("n", "desc").limit(3)ranked a count of9above100and "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 to42(collapsing"01"and"1"into one bucket), and the fix for that turned every key into a string — soOrder.group_by("year")on an integer column returned{"year": "2024"}and broke arithmetic and== 2024comparisons. The schema decides now: numeric columns yield numbers, text columns keep their exact text. Model.paginatereported an empty page instead of raising. The query layer signals failure with an in-band"Error: …"value, whichcountnow raises on butpaginatestill read as zero — a columnar model whose table was missing came back astotal: 0, total_pages: 1with an error string inrecords, and nocatchever saw it. Both the count and the records query raise now.
Auth / security
soli generate oauth github|google. OAuth client scaffold (requiresgenerate 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=jsonemits 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 anyOTEL_EXPORTER_OTLP_*endpoint) turns on distributed tracing: inbound W3Ctraceparentis 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 sharedtrace_id. See Observability. - Production worker default is 2, not one-per-core. With
APP_ENV=production(orprod) and neitherSOLI_WORKERSnor--workersset, 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, andsqliteare 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; thePasetoclass 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 underdocs/from the templates embedded in the installedsolibinary. 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-resourceusesoli generate scaffoldandsoli db:migrate generate. Migrations are documented assoli 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
Pasetoclass 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 prefersSOLIDB_API_KEY. Document get andSOLI_DB_NO_QUERY_CACHEwork on the driver path.
Performance
render_jsonuses sonic-rs (same path asJSON.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 cloudandsoli env downread their target server fromdeploy.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. Parsingdeploy.tomlnow lives on its own, apart from the SSH machinery that still needs a Unix host. Nothing changes on Unix, andsoli deployremains Unix-only.
v1.27.1 — 2026-08-03
Dev tools
soli cloud— immutable releases with a mutable alias.soli cloud deploybuilds an artifact, lands it inreleases/<app>/<id>/— never modified after it lands — repointssites/<app>at it, asks the proxy to deploy, waits for the health check, and only then moves the alias.soli cloud rollbackis therefore repointing a symlink: no rebuild, and the bytes it returns to are provably the ones that were serving before. Plusreleasesand--dry-run, which prints the same plan a real deploy executes rather than a second description of it. Servers come from the existingdeploy.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/cartcreates 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 downreverses 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. Alsolistandurl. Configure it with a[preview]section indeploy.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 longtask/…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_templateis copied into the worktree and then overlaid with the generatedSOLIDB_DATABASE, so a value in the template can never survive into the preview. It defaults to.env.preview.examplerather than.env.previewbecause the generated file setsAPP_ENV=previewand Soli layers.env.previewover.env— a template with that name would be checked out into every worktree and silently win.soli env uprefuses 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 nopublic/sub-root the way an app has one — sosoli serve www/docsrendered every page and 404'd every picture, because the pages embed/images/…and those files sit inwww/public/images/.--assetsmounts 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,304andRangehandling as any other file — a folder gets no generated index, Markdown is not rendered, and a.slv/.erbis 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 stay404. Paths resolve to absolute before the server daemonizes, a typo fails fast, and an MVC app — which already servespublic/— warns that the flag is ignored. Under--deveach 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 toport + 1reads as unhealthy and is quarantined after three such failures — a port race presenting as a broken deployment.--strict-portexits instead.
Fixes
HTTP.*failed intermittently against HTTP/2 APIs — every other page load, same URL. A parallelHTTP.get_all_jsonto an h2 host returned{"error": "Request failed: error sending request for url (…)"}for some URLs while the others succeeded, so a controller readingresponses[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 withruntime 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.
Displayfor areqwesterror stops at the top level, so a transport failure readerror sending request for url (…)and threw away the sentence naming the cause —dns error,connection closed before message completed,invalid peer certificate. EveryHTTP.*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
PATHand 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 runrefuses a session whose user cgroup it does not recognise (nosystemd --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-chromeandgoogle-chrome-stableresolving to the same file count once.SOLI_CHROME_PATHstill 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 readthe 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 servenow works on any folder, not just Soli apps. Pointed at a directory with noapp/controllers/and noconfig/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,ETagandRange;.mdrenders as a styled page;.slv/.erbtemplates execute with the folder as the views root (localspathandparams, no layout); every folder gets a generated index with itsREADME.mdrendered above the listing; and--devlive-reloads on edit. Detection is automatic, with--staticand--appto pin it either way —--appkeeps the old error verbatim. See Static & Markdown Server.- Media and source files open inside the site instead of navigating away. Clicking a
.jpgin 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/.slvlexer-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.curlsends neither header and gets the file;?rawforces 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.slvandindex.erbare each served by their own rule — HTML as-is, Markdown rendered, templates executed. Previously onlyindex.htmlcounted, andindex.mdwas treated as a README. AREADME.mdstill 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 followingprefers-color-schemewith a toggle that overrides it. Fencedsoliblocks 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
cdinto should not publish it to the LAN, so file mode defaults to127.0.0.1(SOLI_HOST=0.0.0.0still opts in); MVC apps are unchanged. Any path segment starting with.returns404— not403, 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.slfile;File.*andImage.*are jailed to the served folder.
Fixes
- Static file extensions now match case-insensitively.
logo.PNGwas served asapplication/octet-stream, so the browser downloaded it instead of displaying it — which reads as a broken image, not as a naming rule..mdand.markdownalso joined the MIME table astext/markdown.
v1.26.3 — 2026-07-30
Fixes
soli fmtrewrote controller hook assignments into calls, silently deregistering every filtered hook.this.before_action(:index) = fn(req) { … }came back asthis.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 byfn. 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 desugarsfoo(args) = valueintofoo(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 fmtemitted a postfixifafter a multi-line@sdbql{ … }block or[[ … ]]raw string, producing a file that would not parse. The guard-clause rewrite collapsesif cond { stmt }tostmt if cond, putting the keyword after the value — fatal when the value prints across lines, since theifthen lands after the closing delimiter. This brokesoli serveboot and madesoli testreport “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 lintno longer walks into locale files. Apps that keep translations in Soli rather than YAML —app/helpers/locale_fr.slreturning a hash of full sentences, one key per line — got hundreds ofstyle/line-lengthhits per language, burying every genuine finding under noise nobody would act on. A directory walk now skips a file under a directory namedlocales/, or whose stem islocale_<tag>/<tag>_localewith a locale-shaped tag (fr,fil,pt_BR,zh-Hans,es-419). The shape check is what keeps the skip honest:locale_helper.slandlocale_switcher.slare 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 checkis unaffected.
v1.26.2 — 2026-07-30
Fixes
soli fmtdeleted blank lines inside anifbody. Reported as “fmt removes the blank line before areturn”, but thereturnwas incidental — any blank line between two statements in anifbody was dropped, while the identical body underfor/whileor 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 anifbody's span line is its last statement, wherefor/whileuse 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() { ... })raisedcannot 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, andfor-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 testnow exercises the production coalescing path. The test server runs with--devso the AQL query log is populated — but--devalso disablesgrouped(fn() { ... })coalescing, so no spec ever ran the combinedLET … RETURN […]path, andassert_query_countmeasured dev's un-coalesced number rather than the round-trips production makes. Interactive--devstill 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
groupedexists for. The query panel now shows an amberN READS · N ROUND-TRIPSadvisory for distinct one-off reads outside anygroupedblock, andassert_no_ungrouped_reads(response)asserts the same in a spec (raw data onresponse["ungrouped_reads"]). Writes and repeated templates are excluded, as are reads already inside agroupedblock — that last exclusion matters because interactive--devdoes 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 withincludes(...), the framework fix for a repeated association read.groupedis 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 newand everysoli generateemit formatted Soli. A freshly generated app was six files away fromsoli fmt --checkclean — so the first thing an agent did, runfmtas the generatedCLAUDE.mdinstructs, produced a diff of files nobody had touched. The scaffold's 30.sltemplates are formatted at rest, and the single write helper every generator shares now runs.slcontent 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 acrossnew+auth+oidc_provider+offline+devices+app_links+scaffold+mailer+component: zero unformatted files, no lint issues, and the app serves/,/posts/new,/loginand/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-linestill stays attached to its target. - A comment that was the entire body of a
catchescaped the block. With no statement to flush before it,rescue/# already exists/endprinted the comment after theend, where it read as documenting whatever came next. Block bodies flush through their closing line now, and thecatchkeyword's line is recorded — without that, a body-leading comment gained a blank line on the second pass. soli generate offlineproduced a controller that could not be parsed.sync_controller.slassigned anif/elseas an expression, which the parser rejects — the sync routes were dead on arrival. Hoisted to a declaration assigned in each branch.- The
offlineanddevicesmigrations tripped 18 lint warnings in the app that generated them. Their deliberately-idempotentbegin/rescuesteps had empty rescue bodies, whichsmell/empty-catchandstyle/empty-blockboth flag — so a user following the documentedsoli lintstep 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.mdfilessoli newgenerates tell the agent to runsoli fmt, not justsoli lint.fmtis now step 1 of the verification loop in the root file, intests/, inapp/controllers/and in the/soli-verifycommand;app/models/,app/middleware/,app/views/anddb/migrations/gained a “Before you're done” block with both commands scoped to that directory.fmtgoes first because it rewrites layout in place: severalstyle/*rules stop firing once it has run, so what lint reports afterwards is the part that needs judgement. Each file also states whatfmtdoes not touch —.html.slvtemplates stay hand-indented, and a""" … """query is rewritten while[[ … ]]is preserved. soli fmtgives an earlyreturna 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. Areturnis now followed by a blank line — with two exceptions, both cases where the blank would only add noise: when the next statement is anotherreturn, so a run of guards still reads as one paragraph, and when theendfollows, since there is nothing below to separate it from. This covers postfixreturn x if cond— in practice the only kind ofreturnwith code after it, the rest being unreachable — and matches the blank the formatter already emits after a block-form guard. See Formatting.
Fixes
soli fmtre-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 andstyle/line-lengthnow 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 lintstep its ownCLAUDE.mddocuments. Two issues, both shipped in the scaffold:application_helper.sl'slink_to_classwas a 136-character line (style/line-length), andauth.slcompared an API key with== ""(idiom/prefer-blank).soli newnow produces a lint-clean app. - The generated
CLAUDE.mdlisted three generators that don't exist, 600 lines after warning against them. Its “Common commands” block still advertisedsoli generate controller|model|migrationwhile 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, anddb/migrations/CLAUDE.mdno longer tells agents to name files withsoli generate migrationeither. soli fmtproduced code thatsoli lintrejected — and in one case code that meant something different. Four defects, all reachable by runningsoli fmtthensoli linton a real app. A blockunless a || bwas reformatted toif !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, trippingstyle/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.fmtstays idempotent; formatting a 105-file app now leavessoli lintclean 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/mailerspreviews 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.emlto 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 theletter_openergem or a separate MailCatcher process for this. See Mailer. - The dev bar has a
toolsmenu, so the dev-only galleries are no longer URLs you had to know./__soli/inbox,/__soli/mailersand/__soli/componentshave 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 ondeliver_nowis a poor way to learn that. Under--devan unconfigured host now captures the message into the inbox and lets the request carry on, the wayletter_openerdoes. Each message is taggedsent(an SMTP server accepted it),captured(never left the process), orfailed— 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--devnothing is captured, the routes don't exist, and an unconfigured host is still a hard error.
Removed
- The dev database browser at
/__soli/dbis 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 (soliin 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_rollupis 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
--devserver 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
pgdriver onto Sequelize, since measuring one stack's hand-written SQL against four ORMs flattered it by 34%. PostgreSQL runssynchronous_commit=offfor the write rows, because SoliDB acks beforefsyncand 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_jsoncarries an interceptor that implements theas_jsonoverride: it evaluated the first argument to check whether it was an instance whose class definesas_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; forrender_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_byandmin_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")androws.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_bykeeps integers integral, so money held as cents cannot silently become a float — it promotes only once a float is seen.avgis always aFloat, because an average is a ratio and integer division would report[2, 3].avg()as2; averaging nothing givesnull, never a0indistinguishable from a real zero mean.group_bypreserves first-seen key order and within-group order;index_byanduniq_byfollow Rails and Ruby respectively on duplicates;max_by/min_byreturn the record and skip records missing the field rather than letting a null win. A record missing the grouping field lands undernull, so counts still total the input length. maxandminreturned 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.reducewithout 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 throughto_i()intoljust,rjust,center,lpadorrpad. All five now refuse a width beyond 1,048,576 characters with an ordinary error.truncateis deliberately not capped, since its argument shortens a string rather than building one. breakandnextcompile everywhere they can appear. Both used to send the handler to the slower engine when written inside atryor inside a lambda. Leaving atrynow runs itsfinallyon 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 exceptType { 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}andType { 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
matchfalling through every arm raised in the engine that runs your tests and evaluated tonullin 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, sox if x < 0works. A binding used mid-expression (as a call argument) still uses the other engine. SOLI_LOG=httpno 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=queryno 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 = 1thenlet x = 2in 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 involvingconststill use the other engine on purpose. - Safe navigation (
&.) compiles natively. Any handler usinguser&.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, sonil&.foo(bar())never runsbar(). A handler using&.no longer registers as a demotion. nextcompiles natively. Likebreak, it was refused, so any loop that skipped an element ran on the slower engine. Both spellings (nextandnext()) now compile forfor,whileand range loops — anext-heavy loop is 2.9× faster. A program that declares its ownnextstill gets an ordinary variable.breakcompiles natively. It was refused outright, so any handler containing abreak— the ordinary way to stop scanning once you have found what you wanted — ran entirely on the slower engine. Abreak-heavy loop is now 2.8× faster, and such a handler no longer registers as a demotion.breakinside atryis still handled by the other engine — that one shape only.finallyis compiled properly now, so handlers using it run at full speed. The previous release refused to compiletry/finallyand fell back to the slower engine — correct, but a whole-handler penalty. The block is now emitted on every way out of atry, including a throw raised inside acatchclause. Afinally-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 nullinstead of failing, and a top-levelthrowdid nothing at all. Areturnfrom inside atryleft 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, athrowat 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,allandincludeson a loaded array worked in tests and raised in production.has_manyaccessors return a plain array, soorg.contacts.order("name").all()lands on one. The engine that runs your tests accepted it; the engine that serves requests answeredCannot access property 'order' on Array. All three now work in both, sharing one implementation so they cannot order differently, andsoli checkaccepts 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#chrwas advertised everywhere but implemented nowhere. Tab completion, the type checker and the member whitelist all listed it, sosoli checkaccepted"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 offerednone?andone?on integers, which are array predicates; those entries are gone.- The optimizer corrupted every
tryblock containing an optimizable instruction. When the compiled engine fuses instructions for speed it rewrites the jump offsets that follow — but atrycarries 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, thecatchwas 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. Atrywith no optimizable instruction was unaffected, which is how it went unnoticed. - A
throwfrom insidemap/filter/each/reducelost its value, andsort_byswallowed it entirely. These methods run the callback from Rust, and a thrown value was destroyed crossing back — sorows.map(fn(r) { throw {"code": 422} })could not be caught as a hash.sort_bywas 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, socatch 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 thenextbug, and these two are the only such sentinels, so the class is closed. A program with its ownlet debug = 42is unaffected. Separately,Model.findandforbidden()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__:nopeon the page. A caught error now reads as its message.finallydid not run when atrywas left early, and could swallow an exception outright. In the engine that serves requests,finallyran only when the block reached its end — sotry { return x } finally { conn.close() }leaked the connection in production while releasing it undersoli test, and a throw with no catch clause was discarded, making the error vanish.finallynow runs on every way out of atry: normal completion,returnfrom the try or from a catch, a handled throw, and an unhandled one. Areturnorthrowinside thefinallyitself now takes over from whatever was in progress, as Ruby'sensuredoes.- 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, soe["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. nextwas 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 waybreakalready is, which routes the handler to the engine that implements it — a loop usingnextnow gives the right answer.- Returning from inside a loop corrupted the loop that called you. A function that does
returnfrom within aforloop 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
substringcrashed on non-ASCII text. The production engine kept a fast path forsum,minandmaxthat handled whole numbers and silently skipped everything else, so[1.5, 2.5].sum()was0and[1.5].min()wasnull— 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.fetchalso now raises on a missing key, as in Ruby, wheregetreturns null. sort_byreturned 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 andnullin the other (it now returns the array, soa.push(1).push(2)chains),arr.get(out_of_range)now answersnulllike every sibling, and comparison errors named their operands backwards —[1] > 1reported "int and array".- Passing an argument to a method that takes none is now an error, not a shrug.
"abc".nil?("junk")returnedfalsein the production engine and raised in the one tests use.class,nil?,blank?,present?,inspectandto_snow 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/lowercasewere unknown to the checker entirely,delete_prefix/delete_suffixwere declared as taking no argument despite needing the affix to strip, andcasecmp?,ascii_only?,assocandrassocwere missing outright.[1, 2].to_s()also ran in the production engine and raised in the one tests use; it is now an alias ofto_stringin both. - Optional arguments the type checker did not know about.
config.get("port", 8080)andfetch(key, default)return the default when a key is missing, andcenter,ljust,rjust,lpad/rpadandtruncateall take an optional pad or omission string. Every one of them ran correctly and failedsoli check, because the checker declared fewer parameters than the implementation accepts. soli checkrejected five working String methods.count,index_of,scan,partitionandrpartitionwere declared as taking no arguments. A zero-argument member is auto-invoked, sos.count("a")resolved to anIntand the checker reportedCannot call non-function type 'Int'— rejecting a call that runs fine, with a message pointing nowhere near the cause.[].pop()now returnsnullinstead of raising. Every sibling already did on an empty collection —shift,first,last,min,max— as does Ruby, leavingpopthe only one that could blow up. It raised in the interpreter thatsoli testuses and returned null in the enginesoli serveuses, so the same line failed in tests and passed in production.inspectkeeps the quotes on nested strings, andhash.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_son a hash previously worked in one engine and raised in the other; it is now an alias forto_stringin 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 letsn.abs()work handed thatnullstraight back. Becausesoli testruns the tree-walking interpreter andsoli serveruns 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 bydefine_method, and universal members likenil?andclass. - 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 raisedCannot access propertyin the bytecode VM, which never checked whether the key held a callable. Because the VM is whatsoli serveruns, 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 -eignored--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. EveryDateTimeaccessor 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. pluckandpickno longer returnnullfor every ORM row. Their field accessor understood hashes and array rows but not instances — which is whatModel.all()andwhere(...)return — soUser.all().pluck("email")came back as a list of nulls instead of raising. One shared accessor now servespluck,pickand 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 atsort_by(fn(x) ...).last().
Language
- Block-form
unless ... end.unlessonly 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 anelsebranch.elsifafterunlessis 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]setpanic = "abort", under whichcatch_unwindnever 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 immediate500(previously the caller waited 40s for a504from a worker that was already gone), and a newsoli_handler_panics_totalcounter surfaces it on/_metrics. Acompile_error!guard now fails the build ifpanic = "abort"ever returns, so the regression cannot be silent again. - Graceful shutdown — rolling deploys no longer truncate requests.
SIGTERM/SIGINTpreviously calledexit(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 clean503 … Connection: close, and requests already in flight run to completion before the process exits — bounded bySOLI_SHUTDOWN_GRACE_SECS(default 25s, just under Kubernetes' 30s default). A second signal exits immediately. - Health and readiness endpoints.
GET /_healthreports liveness —200for as long as the process serves, including mid-drain, so an orchestrator never restarts a container that is already exiting cleanly.GET /_readyreports readiness —503 startingwhile workers boot,200 readywhen serving,503 drainingduring 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=1restores the previous behaviour. No code change needed — existing apps get this on upgrade. --workers 2was 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 theWorker 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. SettingSOLI_WS_WORKERSexplicitly still forces the split at any size, or0disables 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
DateTimeis 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. DateTimelocal-time conversion is cached:end_of_month−48.5%,year−12.5%, category geometric mean −9.3%.chrono'sLocalre-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 thefrom_local_datetimepath from 234.7 ns to 21.6 ns, which is why the month and year boundaries gained most.$TZis still honoured first, exactly as before: resolving through the system zone alone would silently ignore theENV TZ=UTCmost containers set, so a$TZholding a POSIX spec rather than an IANA name keeps the old path and stays correct. Verified by diffing everyDateTimemethod 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_daysandDateTime.from_unixeach 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.fieldandarr[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 nativeString.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 fourthis.reads −21%,hash[key]−17%. Every model attribute read and everythis.in a controller goes through this path, so applications get it on upgrade with no code change. joinon 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 renders2.0where Soli renders2.avgseparately cloned every element where the neighbouringsumread by reference. Measured on 1000 elements:join5.95 → 2.09 ms,avg0.785 → 0.157 ms. Everyjoin,to_stringand#{}interpolation of a number benefits.reverseis 8× faster,swapcase−70%,chars−25%. All three decoded and re-encoded UTF-8 where the answer needed neither.reverseandswapcasenow 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.charswas 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.reversegoes 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.intersectionanddifferencealso kept two hash sets and hashed every element twice — once for “is it inb”, once for “have I emitted it” — where a single set answers both: seed it withb, then remove on a hit for intersection, or insert for difference. Measured on 20,000 elements:flatten0.171 → 0.115 ms,intersection1.164 → 1.001 ms,difference1.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,intersectionanddifferencewere 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 arrayuniq()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×),intersection35.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 compares1 == 1.0),-0.0and0.0still collapse, everyNaNstill survives, and arrays/hashes still dedup structurally. Hash methods were measured too and were already fine —getandhas_keyare 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 defaultsoli newlayout callscsrf_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, soPost.pluck("title", "slug")returnedPostinstances carrying only those two fields — they read fine, but they looked like models, andsaveon 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: preferModel.pluck(...).alloverModel.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
DateTimeserialised 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, matchingto_iso(). For the same reasonstr(dt)printed<DateTime _ts: 1794744000000000000>, leaking the internal field; it now prints the same local wall clockto_string()returns. Both are behaviour changes, but neither previous output was usable. - The
DateTimemonth and year boundary methods crashed on daylight-saving dates.beginning_of_month,end_of_month,beginning_of_yearandend_of_yearbuilt 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). UnderTZ=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_hourand its neighbours returned aFailed 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 devicesscaffolds aDevicemodel,POST /devices, prune helpers, anddeliver_to_userforPush.deliver(migration usesbegin/rescueif the collection already exists).soli generate client android|ios|linux|windowsemits WebView shells (Android optional--fcmGradle path; iOS posts APNs tokens).soli generate app_linkswrites well-known host files;soli generate offlineadds/sync/push,/sync/pull, andsoli_outbox.js. Desktop artifacts accept--open/ scheme URLs after the launch token; camerascan=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 webDeviceMotion/DeviceOrientationevents, which fire in mobile browsers and both WebView shells — a thin client helper, not a native bridge. Each returns aPromise<{ 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 referencingsoli.sensorsinline or callingmotion_sensors()for external JS; a page that does neither downloads nothing. See Motion Sensors.
- 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
SpecifiedLegalOrganizationat all — valid EN 16931 (BR-CO-26accepts 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_idnow 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_schemeoverrides 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 ascompany.registration/customer.registration, so the PDF can print what the XML carries — theinvoice_compliantandcredit_notesamples 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-keygenmakes a keypair,soli sign-updatesigns a manifest, and building drops a.update.jsonstub to merge in. A newUpdaterbuiltin (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:
ammonia4.1.4 (RUSTSEC-2026-0213). The advisory covers an XSS inammonia4.1.3, where script could be smuggled through SVGanimate/setanimation tags. Soli’ssanitize_htmlwas 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 failedcargo audit, which gates every push and PR. Nothing changes in howsanitize_htmlbehaves.
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.sendmaps a payloadbadgeto the Android notification'snotification_count(APNs already setaps.badge), soPush.deliverbadges a closed app on either platform. Open-app Androidbadge(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 aprunelist 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. PlusGeo.*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 emitssoli:scanwith the decoded value. NativeBarcodeDetectorwhere the host has one, a page-supplied decoder where it does not. The reason it exists rather than leaving you to six lines ofgetUserMedia: 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.p8per team, no annual certificate churn), theapsenvelope built for you, and a{status, reason}result rather than an exception, because a dead device token is an outcome to handle. Receiving requires theaps-environmententitlement 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 browserGET. It reaches only clients with the app open and returns how many, so real push stays one line away. Existingnew 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 (includingapplication/manifest+json, without which browsers ignore a PWA manifest entirely). - Desktop apps embed in a native shell.
SOLI_DESKTOP_NO_WINDOW=1stops 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.
codesignaligns 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 ordinarysoliCLI. 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-bytenullbody. The extraction directory is canonicalized before anything records it. Reproducible anywhere by pointingTMPDIRat a symlink.
v1.23.3
July 21, 2026Testing
- 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, 2026Testing
- Browser specs declare their viewport —
viewport("mobile")in adescribebody sets the size every test in that suite renders at, and nested suites inherit it; callingviewport(...)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, somatchMedia("(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 madecodesignreject 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__LINKEDITover 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 withcodesignspecifically — a signer that regenerates__LINKEDITstill 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, 2026Auth & security
- Soli can be an identity provider —
soli generate oidc_providerscaffolds 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 theid_tokenusing 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, butsoli generate authemitted1784481604create_users_1784481604.sl, whose version part is not numeric. Every migration it produced was skipped without a word:soli db:migrate upreported “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_keyonly handled certificates andprivate_from_pemonly 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- 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 withvalignto centre the label. Composes withcolspan, 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
atat 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
atrestores 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 leadingmoveis 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
movegap 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
repeator 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: 0reserves 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_mapbuiltin 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
atelement — 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 mixingatwith 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
boxelement — 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 therect+ hand-computed height + compensatingmovepattern that panels, callouts and signature areas previously needed, and whose numbers had to be re-tuned whenever the text changed. Supportspadding(a number or per-side),width,fill,border,borderWidth,radius,dashandgap; 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, spenddonut),credit_note(negative amounts, reference-to-original band),quote_sections(per-section subtotals and a signature acceptance box) andquote_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
repeator data-boundtablenested inside anotherrepeatnow resolves itsdatapath against the current item before falling back to the document root, so"data": "lines"inside a repeat oversectionsbinds 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 akinddiscriminator and unpicked with nestedifblocks. 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_invoicebuilds its own render data, so it exposesinvoice.*/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 usepdf_facturxwith a supplied XML. scripts/gen_pdf_previews.sh— renders the PDF samples and rasterises page 1 of each towww/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-amd64joins the release targets and a CI job keeps it from re-breaking. Compile-verified but not yet run on Windows. darwin-amd64is published — Intel Macs were resolving to that artifact name insoli updateand getting a 404, because CI had never built it.
Testing
- Browser testing is built in —
soli test --browserdrives a real headless Chrome over the DevTools protocol, spoken from thesolibinary 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, notelement.click(), so an element hidden behind an overlay fails the way it would for a user. Fields resolve by CSS selector,<label>text,nameorplaceholder. See Browser Testing. - Opt-in, so the default suite stays fast — a spec is a browser spec when a
browserdirectory appears in its path. Plainsoli testsets 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.--browserchecks for one up front and fails with what it looked for, rather than thirty seconds later on the firstvisit();SOLI_CHROME_PATHpoints it somewhere else, and--headedshows the window. - Sign-in carries into the browser — the browser shares the request helpers' cookie jar in both directions, so an existing
login()inbefore_eachworks unchanged, and a sign-in performed by clicking a form is visible to a laterget()and tosigned_in(). One browser per test worker, launched on first use and reused across tests, withsessionStorage,localStorageand 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 includingsoli-ignoreislands and focus retention, and the dev bar's panels andAlt+Dtoggle.
Auth & security
- Fixed:
jwt_verifyrejected every token carrying an audience — the underlying library validatesaudby default, and Soli never told it which audience to expect, so any token with anaudclaim failed withInvalidAudience. In practice that meant no OpenID Connectid_tokencould be verified at all — not from Google, not from Auth0, not from anywhere — andjwt_decode_unsafecould not even inspect one. Audience is now checked only when you ask for it, the same wayisshas always worked. jwt_verifycan now check who a token was meant for — newaudience,issuer,subjectandleewayoptions. Settingaudienceorissuermakes 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, passaudience— without it a token minted for client A is accepted by client B.jwt_signcan set the headerkid— plustyp, and the registered claimsexp(absolute),nbf,aud(string or array, per RFC 7519),issandjti.kidis 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 bothexpandexpires_inraises 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)andCrypto.random_token(n = 32), all drawn from the OS entropy source. There was previously no way to generate a secure random value from Soli — onlyuuid_v4()andnanoid(), andCrypto.random_hexwas referenced by the documentation without existing.random_tokenreturns unpadded URL-safe Base64, the right shape for OAuthstate, PKCE verifiers, authorization codes and refresh tokens.nis a byte count throughout, sorandom_hex(32)gives 64 characters, matchingopenssl rand -hex 32.
Language
- URL-safe Base64 — new
Base64.urlsafe_encodeandBase64.urlsafe_decodeuse 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,X509and thejwt_*functions now type-check — all of them worked at runtime but were unknown tosoli check, so a script merely mentioningBase64.encodefailed 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 asconfigure()boundnullto 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 passednullstaysnull(it is a supplied argument), and a later default may reference an earlier parameter, as indef f(a, b = a * 2). - Named arguments no longer slow down the handler that uses them — a labelled call such as
configure(port: 3000)orget("/", "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 andprint. matchfollowed bytry/catchno longer misbinds the exception — amatchwith a literal arm left the VM's value stack one slot short, so a latercatch eboundeto 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 likefor n in [1, 2]whose body began[10, 20].each(...)became[1, 2][10, 20]and failed to parse; the same applied to brace-lessif/whileheads and to two adjacent statements. Worse, it was not always a parse error —while i < 1followed by[7].each(...)quietly parsed asi < 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 asrows[0]is unchanged. Reaching this bug was easy viasoli fmt, which converts braced loops to theendform. - 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.slwhile every lookup uses/.soli build --protectstill reported success and produced a valid-looking.soliwith 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/passwdis 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 theFile.*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/catchcan 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 bypasstry/rescue, as intended.
v1.22.0
July 19, 2026Language
breakis now a loop keyword — exits the innermost enclosingwhileorforloop, with postfix conditions (break if cond/break unless cond). It propagates correctly out of nested blocks,ifbranches andtry/catch(afinallyblock still runs before the loop exits), and is absorbed at the function boundary — abreakinside a lambda or function body does not break an outer loop. Not compiled by the bytecode VM: a handler containingbreakfalls 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 todebug()— 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 anybreak()call in your code withdebug(). See Debugging.
Performance
- Much faster
soli graph buildre-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)(andsuper.native_method(args)) no longer allocates a boundNativeFunctionwrapper 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 VMCallMethodpath 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 hotDateTimeaccessor loop: about 1.55× faster on the tree-walker and 1.62× on the VM (~−36% / −38% wall time).
v1.21.4
July 16, 2026Dev tools
- C# call graph in
soli graph build— the multi-language extractor now walks C# method bodies forcallsandnew X()instantiatesedges, 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:instantiateslinks only to project classes (framework types likenew List<T>()are skipped), andcallslinks only unambiguous names (overloaded/shared names are dropped, not mis-linked).
Fixes
soli graph buildno longer hangs on a slow embedding endpoint — embedding HTTP requests (run by default) had no timeout, so a stalled or unreachableSOLI_EMBEDDING_URLwould block the whole build forever. Requests now time out afterSOLI_EMBEDDING_TIMEOUT_SECS(default 60s) and fail with an actionable message that distinguishes a missing key from an endpoint that timed out.--no-embedremains the escape hatch.
v1.21.3
July 16, 2026Dev tools
- Richer code-graph edges —
soli graph buildnow links instance method calls on locally typed variables (let u = new User(), typed lets,User.find/ factories),partial(...)/ view→partialrenders,redirect("/path")asredirectsto matching routes, and baresuper(...)/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 barepartial("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 truncatedsnippet(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, …); aredirectto a path served by several verbs prefers theGETroute.
v1.21.2
July 16, 2026Dev 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 whosefilestarts 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 buildon 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 withDocument with _key '…' already existsand 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, 2026Fixes
- arm64 Linux release build — OpenSSL is now vendored (compiled from source), so the linux-arm64 cross-build no longer installs
libssl-dev:arm64from 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, 2026Security
- 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,splitand model/format validation now compile patterns through the same size- and nesting-bounded cache theRegexclass 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 raisesinvalid 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-databody 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 fromparams/ the form hash and files from the uploads API (the raw bytes are still retained). CSRF verification and_methodoverride 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) intosoli_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 overembedding);--no-embedbuilds a purely structural offline graph and--dry-runprints 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;--freshforces 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).--jsonreturns a structured result (each seed + itsneighbors) an agent parses directly;--limit/--hopstune 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 --devwith an embedding key configured and the code graph reindexes itself on every.sl/.slvsave, so it never goes stale while you work (no flag needed;SOLI_GRAPH_WATCH=0/1forces off/on). Rides the dev file-watcher on a background thread, reuses the live route table (never re-executesroutes.sl), and is incremental on embeddings — unchanged nodes keep their vector, only what changed is re-embedded. - Any codebase (multi-language) —
soli graph buildnow 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 queryand the rest are reused unchanged.
v1.20.0
July 14, 2026Performance & 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 oneValue, so this shrinks runtime memory across the whole interpreter. - Smaller AST — parsed nodes shrank (
Expr144 → 80 B,Stmt360 → 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-timesize_ofguards 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=0skips view helpers (incl. i18n locale tables) in job interpreters, the default job-pool size is now1, and a new Keeping memory low guide documentsSOLI_WORKERSand friends — the worker count is the primary lever on baseline RSS. - Fix:
SOLI_WORKERSis now honored — the CLI previously ignored the env var (only the--workersflag 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 atSOLI_WORKERS=2;--workersstill overrides.
v1.19.0
July 14, 2026AI & 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 aftersimilar/hybrid/graph_ragwhen you want to bias the order toward a phrase. - Server-side auto-embeddings — declare
embedding_sourceon 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-modelbefore_save embed(...)hook. - New raw-SDBQL retrieval surface — reachable via
db.query/@sdbql{}and now documented: filteredVECTOR_SEARCH(…, { filter }), LLM/lexicalRERANK, storedRAG_PIPELINE, point-in-timeDOC_AS_OF/DOC_HISTORY, and self-refreshingCREATE MATERIALIZED VIEW … REFRESH "5m".
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)andassert_max_queries(response, n)hold an endpoint to a query budget. Every response also exposesresponse["query_count"]andresponse["n_plus_one"]for custom checks. soli test --fail-on-n1— a suite-wide N+1 tripwire. With the flag, anyget()/post()/request()that triggers an N+1 fails its test automatically — the same detection and message asassert_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/awaitkeywords — they were never implemented (writingawait <expr>hit an internal panic), so this deletes unreachable surface only. Theawait()builtin that resolves a future (e.g. the handle fromSystem.run(…)) is unchanged —await(x)is now an ordinary function call, andasync/awaitare 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
flattenin 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, 2026Grouped by area; jump to a section:
Real-time & LiveView
- Reactive live queries —
Model.live_where(filter)inside a LiveView handler runs the query likewhere(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;nullmatches 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
streamsub-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-levelsoli-window-keydown/soli-window-keyup;soli-hreffor a full-page leave. A handler may returnredirect: "/path"(the client navigates, no further patch) orjs: [{ 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, plusthis.pushEvent) — register onSoliLiveView.hooksbefore connect. In-flight events addsoli-loading/soli-<event>-loading;soli-disable-withswaps the label and disables the control until the next patch.soli-click-awaycloses dropdowns when a click lands outside. - In-socket navigation and nested child sockets.
soli-patch="/path?q=1"updates the address bar and sendsevent == "patch"withhref/path/query(browser back/forward too). A handler may returnpatch: "/path"or{ "url": "/path", "replace": true }. Nesteddata-liveview-urlmounts become their own sockets after each parent render (implicit ignore island). Phoenix-stylelive_componentassigns remain out of scope. - File uploads.
soli-upload="handler"POSTs each file to/live/upload(multipart + CSRF, 8 MiB default) and then sends the handlerparams["file"]in the same shape asfind_uploaded_file(base64data). Progress viasoli-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 sendsoli-assign-*; the runtime merges_assignsonto the parent before the handler runs. Isolateddata-liveview-urlsockets 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 theModel.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.
AI & Search
- Embeddings & generation builtins —
embed(text)/embed_batch(texts)andllm_generate(system, user)talk to any OpenAI-compatible endpoint (configured viaSOLI_EMBEDDING_*/SOLI_LLM_*), keeping credentials out of app code. - One-call RAG —
Model.rag(question[, opts])embeds the question, ANN-searches thevector_indexfor the top-k rows, builds an LLM context from each row's text field, and returns{ answer, sources }. - Streaming LLM — inside an
sseblock,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.
API
- Opt-in OpenAPI —
SOLI_OPENAPI=1exposes an OpenAPI 3 spec generated from the routes at/openapi.jsonand a Scalar API-reference UI at/openapi(:id→{id}path params,controller#actionoperationId, controller tags). 404 unless enabled; served in every environment once on, like/_metrics.SOLI_OPENAPI_TITLEsets 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>_counterlocals. - 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 acomponent/propslint rule checks well-formedness. @ivarpropagation — controller instance variables (@current_user,@posts) flow into partials and components automatically, without threading them through everyrender.soli generate componentpluspaginateandnumber_with_delimiterview 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/componentslists view components with their declared props and a live preview (example data from a<%# preview: {json} %>header). - Mailer preview gallery — dev-only
/__soli/mailersrenders 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).
- IMAP client — an
Imapbuiltin 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, andCrypto.ledger_hashbuiltins plusModel#to_h;Cryptois 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 authscaffolds password reset, email confirmation, remember-me, and account lockout. - Form builder — Rails-style
form_with(with block syntax), per-form CSRF tokens, and_methodoverride. - Strong params & nested params — Rack-style bracket nesting across form/multipart/query,
fields_forsub-builders, andpermit(). - 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 --standalonewith cross-target runtimes;soli_versionminimum-version gate insoli.toml. - JSONP read/write (
render_jsonp,JSON.parse_jsonp,HTTP.get_jsonp); dev-modeattr_accessiblemass-assign-drop warning. - Fix — background-jobs callback route preserved across
config/routes.slhot reload.
v1.16.0
July 5, 2026- Single-collection inheritance (STI) —
class Admin < Usershares the base's collection with atypediscriminator, inheriting its validations, callbacks, relations, and scopes.
v1.15.1
July 5, 2026- Association writers —
owner.posts << recordandowner.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 routeslister,content_for/ namedyield, 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), andpdf_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
.solibundles;soli buildaccepts 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_HOSTto 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
ammoniamutation-XSS advisory.
v1.13.5
June 30, 2026- Fix —
soli db:migratecreates the SoliDB database if it doesn't exist yet, instead of failing with a 404.