ESC
Type to search...
S
Soli Docs

Changelog

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

Unreleased

Nothing yet — changes land here as they reach main.

v1.25.1 — 2026-07-28

ORM

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

Benchmarks

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

Fixes

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

v1.25.0 — 2026-07-27

Language

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

Language

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

Operations

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

Performance

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

Fixes

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

v1.24.1 — 2026-07-24

Native & mobile

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

PDF

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

Dev tools

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

Auth & security

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

v1.24.0 — 2026-07-23

Dev tools

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

v1.23.4 — 2026-07-21

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

Dev tools

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

Fixes

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

v1.23.3

July 21, 2026

Testing

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

v1.23.2

July 21, 2026

Testing

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

Packaging & desktop

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

v1.23.1

July 19, 2026

Auth & security

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

v1.23.0

July 19, 2026

PDF

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

Desktop apps

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

Testing

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

Auth & security

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

Language

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

Fixes

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

v1.22.0

July 19, 2026

Language

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

Performance

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

v1.21.4

July 16, 2026

Dev tools

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

Fixes

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

v1.21.3

July 16, 2026

Dev tools

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

v1.21.2

July 16, 2026

Dev tools

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

Fixes

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

v1.21.1

July 16, 2026

Fixes

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

v1.21.0

July 16, 2026

Security

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

Performance & Memory

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

Dev tools

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

v1.20.0

July 14, 2026

Performance & Memory

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

v1.19.0

July 14, 2026

AI & Search

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

Testing

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

Language

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

Fixes

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

v1.18.0

July 12, 2026

Grouped by area; jump to a section:

Real-time & LiveView

  • Reactive live queriesModel.live_where(filter) inside a LiveView handler runs the query like where(filter).all() and subscribes the view, so a later write re-renders it automatically. Per-row matching wakes only subscribers a flat-equality filter's changed row satisfies (numeric-aware; null matches a missing field); the string form, deletes, and transaction commits wake conservatively, and transaction writes wake on commit. Single-process.
  • LiveView collection streams — a handler can return a stream sub-hash to push targeted DOM ops (append/prepend/insert/remove/reset) straight to a container by id instead of re-rendering the whole list (Phoenix streams / Turbo Streams model). Streamed rows live outside the diff shadow — ideal for chat logs, feeds, leaderboards.
  • Unified broadcast()broadcast(channel, payload) (and the Model.broadcast(payload) shortcut) fans a payload out to every WebSocket connection in a channel and every SSE subscriber of the topic of the same name, in one call. Non-string payloads auto-serialize to JSON; returns the SSE subscriber count.
Reactive live queries: a write is matched per-row, then only matching LiveViews are woken, diffed, and patched
Live queries: a write is matched per-row, then only matching views are woken and diffed.
Unified broadcast fans a payload out to both a WebSocket channel and an SSE topic of the same name
broadcast(): one call reaches WebSocket and SSE subscribers of the same name.

AI & Search

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

API

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

View Components

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

Developer Tools

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

Mail

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

Testing

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

v1.17.0

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

v1.16.1

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

v1.16.0

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

v1.15.1

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

v1.15.0

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

v1.14.0

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

v1.13.8

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

v1.13.7

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

v1.13.6

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

v1.13.5

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