Changelog
All notable changes to Elyra Framework are documented here. The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.
While Elyra is pre-1.0, minor versions may contain breaking changes; these are called out under Changed with a migration note.
Unreleased
0.11.0 — 2026-10-10
The resource templates release. A live command that takes arguments is now
something an AI agent can watch, not only call. customers_show(id) becomes
app://customers_show/{id}, and customers_index(query) becomes
app://customers_index{?direction,page,per_page,search,sort}. The agent fills
in the values, reads the result, and subscribes to it. It's then told when
that customer, or that search, changes, the way a window is. See
the guide and
RFC 0004.
Upgrading: nothing to change. Templates appear on their own for live commands an agent is already granted. They add no reach: the agent could already call each one as a tool with any arguments.
Added
- MCP resource templates
(RFC 0004).
- A granted
#[command(live)]that takes arguments gets a template. The command must not need confirmation, and each argument must be a scalar, or a struct of scalars, one level deep. - The template is read off the tool's input schema. Required variables are
path segments, in argument order:
app://customers_show/{id}. Optional ones are the query, with a struct's fields in name order:app://customers_index{?direction,page,per_page,search,sort}. resources/templates/listlists them.rata mcp inspectshows each template, and for a live command without one, why: a list, a nested object, two variables with one name.- An expanded URI reads and subscribes like a plain resource:
resources/read,subscriptions/listenandresources/subscribetakeapp://customers_show/12orapp://customers_index?search=ada. A subscription is told only when what its arguments select changes. - The values are percent-decoded (
+is a plus) and converted by the variable's schema type: an integer, a number,true/false, or one of an enum's values. - These are
-32602"Invalid arguments", with what's wrong per variable indata.errors: a value that doesn't convert, a parameter the template doesn't name, a repeated one, the wrong number of path segments, or a URI over 2 KiB. These are caught before the command runs. A failure of the command's own validation is answered the same way, with its messages. A subscription to such a URI isn't acknowledged. completion/completesuggests a template variable's values from its schema: an enum's values, ortrue/false, filtered by the typed prefix (case-insensitively). The server declarescompletions.- The example app's
filter_todos(done)is a template,app://filter_todos{?done}. The CI Inspector check readsapp://filter_todos?done=false, and is refused?done=maybe. The guide has a resource templates section.
- A granted
0.10.2 — 2026-10-10
A hardening release. Single-instance proves its per-install token instead of
sending it, so a process that takes the endpoint first learns nothing, and
unique / exists fail the field instead of panicking when the database
can't be queried.
Upgrading: nothing to change in code. The single-instance protocol is new
(ELYRA-SI/2), so a 0.10.2 build launched while an older one is open starts
its own window instead of handing over; quit the old one first. Translation
files gain one key, validation.unavailable; until it's translated, the
English message is used.
Fixed
- Single-instance no longer sends its token in the clear. The second
launch sent the per-install token to whatever answered on the endpoint. On
Windows that's a loopback port another process can take first. It could
learn the token there, then inject deep links into the real app. The
handshake is now a challenge-response: HMAC-SHA256 over fresh nonces from
both sides, the primary first, with the payload covered by the client's
proof. It's the scheme the MCP endpoint uses, now shared (
proof).- An impostor sees neither the token nor the payload.
- The protocol is
ELYRA-SI/2. A launch of the new version while an older one is running doesn't reach it, and starts on its own; quit the old one first.
unique/existsno longer panic when their query fails (a locked database, a dropped connection, a missing table). The field fails with the new message keyunavailable("The email could not be checked right now. Try again."), and the reason is logged underelyra::validation. The input isn't let through. A count that couldn't be read used to passuniquesilently; it now fails the same way.
0.10.1 — 2026-10-05
A fix release for migrations. rata migrate now runs the Rust migrations
that make:resource writes, and a rollback no longer forgets a migration of
the other kind. See migrations.
Upgrading: nothing to do for most apps. ELYRA_MIGRATE=up now applies the
pending SQL files in migrations/ too, in the same batch, and prints its
summary rather than logging it. elyra_db::Error has a new variant,
UnknownMigration, which an exhaustive match on it needs an arm for.
Migrator::rollback now refuses a batch that holds a Rust migration.
Fixed
rata migrateruns Rust migrations. The resourcesmake:resourcegenerates, andmake:migration --rust, are compiled into the app, sorata migrateused to answer "Nothing to migrate." and create no tables. Now, whensrc/has aRustMigration,rata migrate,migrate:statusandmigrate:rollbackrun the app in its migrate mode, which applies both kinds in one batch. A project with only SQL files is migrated directly, as before.- A rollback no longer forgets what it can't undo. SQL and Rust migrations
share one history table and batches, but each rollback only knew its own
kind. It dropped the other kind's records without running their
down, leaving the tables in place and the nextmigratefailing on them. That wasrata migrate:rollbackwith Rust migrations in the batch, orELYRA_MIGRATE=downwith SQL files in it. Now every migration in the batch runs its owndown. A batch holding one that's neither a file nor registered isn't rolled back: the newelyra_db::Error::UnknownMigration.
Added
Migrator::run_all,rollback_allandstatus_allhandle the SQL files and the registered Rust migrations together.ELYRA_MIGRATE=statuslists them, andELYRA_MIGRATIONS_DIRpoints the app's migrate mode at the SQL files (defaultmigrations).
Changed
ELYRA_MIGRATE=upapplies the pending SQL files too, in the same batch as the Rust migrations, and the mode prints what it did (asrata migratedoes) instead of logging it.Migrator::rollback_rustis nowrollback_all.Migrator::rollbackrefuses a batch with a Rust migration in it, since it can't run that migration'sdown.elyra_db::Errorhas a new variant,UnknownMigration. An exhaustivematchon it needs an arm.
0.10.0 — 2026-10-05
The AI agents release: every Elyra app can be an
MCP server. You grant the abilities an
agent may use, and the commands behind them become tools for Claude, Cursor
or VS Code, with JSON Schemas built from their types. They run through the
same middleware and validation as a frontend call. The client launches
myapp --mcp. When the app is open, the agent works inside it, and the
windows update as it goes. When it isn't, the app runs without a window.
What can't be undone waits for the user to confirm it in the client. Live
commands are resources an agent can watch. Every call is logged, dispatched
as an event, and shown on a channel for an in-app indicator. See
the guide and
RFC 0003.
Upgrading: one break, small. CommandRequest has a new field, origin,
so code that builds one by hand must set it: origin: Origin::Frontend.
Middleware that only reads a request is unaffected. Everything else is new
API: nothing is exposed to an agent until the app calls App::mcp(..).
Added
- An MCP tool catalog — step 1 of
RFC 0003 (every Elyra app is an MCP server).
App::mcp(Mcp::new().allow_abilities([..]).confirm(..))chooses which commands an AI agent may call: those whosecanability it grants, in a grant separate from the frontend's. Commands without an ability never are.elyra::mcp::catalogturns them into tool definitions. The doc comment becomes the description, and JSON Schemas (2020-12) for the arguments and the result are built from the specta types after serde's rewrites (renames, tagging, flattening).Optionfields may be left out of an input.rata mcp inspect [--json]lists the tools.#[command]now records its doc comment (Command::description()).
- An MCP server — step 2 of RFC 0003.
McpServerspeaks JSON-RPC 2.0 over the stdio framing, one message per line, for both protocol eras:- 2026-07-28: per-request
_meta,server/discover,resultType, andttlMs/cacheScopeontools/list; an unsupported version gets-32022with the supported list. - the
initialize-based 2025-11-25 / 2025-06-18 / 2025-03-26, for the connections that open with it.
- 2026-07-28: per-request
tools/callmaps the named arguments onto the command's positional ones (a left-outOptionisnull) and dispatches through the full middleware pipeline. The value comes back asstructuredContentplus text. A validation bag, a command error, a panic, an unknown argument or a type mismatch is a tool error (isError) the model can act on, with the field messages.ELYRA_MCP=stdioserves it headless, without a window, usingApp::databaseorDATABASE_URL. (Since step 3, it's the same as--mcp.)TestApp::mcp()is an in-process client, withlist_tools,call,requestandraw.- Checked with the official MCP Inspector CLI against a generated app: listing tools, and a call that wrote a row.
myapp --mcp, the MCP shim — step 3 of RFC 0003. It's what an MCP client launches.- When the app is running, the shim pipes MCP to it, so the agent works in the open app: the same database, live queries and events, and the windows update as it goes.
- When it isn't, the shim serves MCP headless, as
ELYRA_MCP=stdiodid. - A running app with
App::mcpserves a private endpoint for it: a0600Unix socket beside single-instance's, or loopback ports of its own on Windows. - A connection is served only after a challenge-response on the per-install token: HMAC-SHA256 over fresh nonces from both sides, with the server proving itself first. The token never crosses the wire, so a process that takes the socket path or a port first learns nothing, and the shim runs headless instead.
--mcpis checked before single-instance, which would otherwise hand the launch to the running app and exit.
- Confirmation in the MCP client — step 4 of RFC 0003. A tool whose
ability
Mcp::confirmnames runs only once the user says yes.- A 2026-07-28 client gets a multi round-trip:
input_requiredwith anelicitation/createand arequestState, and the call runs when the client retries with the user'saccept. - The question names the client and the tool, and gives the first line of the command's doc comment and the arguments.
- The state is signed with a key that lives only in the process (HMAC). It's bound to the tool, the arguments and the client, and expires after 5 minutes. It runs the call at most once, so an accepted delete can't be replayed; a state that doesn't fit is asked again.
- A legacy client that declared elicitation (2025-06-18 or later) is asked
with a server-initiated
elicitation/create. - A client that can't elicit gets a tool error, and the tool doesn't run. So does a decline, a dismissal, or no answer within 10 minutes.
TestApp::mcp().confirming("accept" | "decline" | "cancel")answers as the user would.ToolResult::confirmationis the question that was asked.
- A 2026-07-28 client gets a multi round-trip:
- Live commands as MCP resources — step 5 of RFC 0003.
- A granted
#[command(live)]that can run with no arguments given is a resource,app://<command>. That means every argument is anOption, or a struct whose fields all are (it's read with{}). A command that needs confirmation isn't one.resources/listlists them, andresources/readruns one as the agent, withttlMs: 0. subscriptions/listenwithresourceSubscriptionsfollows them in the live registry. The client getsnotifications/resources/updated, carrying the subscription's id, whenever the live registry would push a new result to a window: coalesced, only when it changed, only for what it read. The acknowledgement lists the URIs it honors.notifications/cancelledends it, and so does the connection closing.- Legacy clients use
resources/subscribe/resources/unsubscribe. - Through the shim, one agent sees another's writes, and the window's, because they share the running app's live registry. Two headless processes don't.
LiveRegistryre-runs carry the subscriber'sOrigin.TestApp::mcp()gainsread,listenandnext_message, andConnection::outboxdrives a connection by hand.
- A granted
- Seeing what an agent does, and holding it back — step 6 of RFC 0003.
- Every tool call and resource read dispatches
AgentCalled { tool, client, ok, millis }in the background, for an audit log (App::listen). It doesn't carry the arguments, which may be personal data. - The
elyra:mcpchannel carries astartedand afinished(withok) to every window.onAgent(handler)in@elyra/runtimesubscribes to it, for a "Claude is adding a customer…" indicator. - Tools are rate limited per tool, across connections: 60 calls a minute
by default (
DEFAULT_RATE_LIMIT).Mcp::rate_limit(ability, max, per)changes that for an ability, a namespace or*; the last limit that matches wins. A throttled call is a tool error, refused before the user is asked to confirm it. Reads count; re-runs for a subscription don't.
- Every tool call and resource read dispatches
- Connecting a client, and the guide — the rest of step 6.
rata mcp install [--client claude|cursor|vscode] [--release]builds the app and prints the config that has the client launch it with--mcp(theclaude mcp addline too). It edits nothing.rata mcp inspectmarks the tools that are resources with their URI.- The example app is an MCP server: its todos are tools,
list_todosis a resource, deleting asks the user first, and the window toasts what an agent does. - CI drives it with the official MCP Inspector CLI
(
scripts/mcp-inspector.sh). - docs/mcp.md is the guide.
CommandRequest::origin—Origin::Frontend, orOrigin::Agent { client }for an MCP call, so middleware can tell them apart.CommandRegistry::dispatch_fromdispatches with one.
Changed
CommandRequesthas a new field,origin. Code that builds aCommandRequestby hand needs to set it (origin: Origin::Frontend). Middleware that only readsnameandargsis unaffected.
0.9.1 — 2026-10-05
A fix release: the JSON cast now works with Postgres JSONB columns, so a
cast = "json" field can use the schema builder's t.json() on every driver.
Upgrading: nothing breaks. An existing TEXT column for a JSON cast keeps
working unchanged, and so do custom casts, since Cast's new methods have
defaults. To move a Postgres column to JSONB, write a migration that alters
it (ALTER TABLE … ALTER COLUMN … TYPE JSONB USING …::jsonb); nothing does it
for you.
Changed
make:resource --generatemakes ajsonfield's column witht.json—JSONBon Postgres,TEXTelsewhere — instead oft.text, now that the JSON cast works withJSONB. Existing migrations are unchanged.
Fixed
- The JSON cast works with Postgres
JSONBcolumns. Acast = "json"field on at.json()column (JSONBon Postgres) couldn't be written — "column is of type jsonb but expression is of type text" — or read: theAnydriver rejects the whole row with "does not support the Postgres type Jsonb". On Postgres the cast now converts in SQL both ways, withCAST(CAST($n AS TEXT) AS JSON)when writing andCAST(col AS TEXT)when reading. That works for aTEXTcolumn too, which keeps its JSON exactly as written.Castgainsselectandbind, with defaults, so a custom cast can do the same; existing casts compile unchanged.- Covered on Postgres 16, MySQL 8 and SQLite, against a mix of
t.jsonandt.textcolumns: insert, find, update to NULL,get,paginateandfirst.
0.9.0 — 2026-10-05
The live queries release. A #[command(live)] is a command the frontend
subscribes to. Elyra records the tables it reads, re-runs it when a write
changes one, and pushes the new result to every window watching — written in
that window, another one, or a background job. On the frontend it's one store,
live.customers_index(query), typed by codegen. rata make:resource now
generates live lists and detail views, so a customer added in one window shows
up in the other without a reload. See the live queries guide
and RFC 0002.
Upgrading: nothing breaks. Command::live() defaults to false, and the
rest is new API. Resources generated with 0.8 keep their non-live views. To
get live ones, regenerate the resource the way you made it (this overwrites
its files):
- made with
--generate:rata make:resource <Model> --generate <the same fields> --force. The migration keeps its version. - built on your own model:
rata make:resource <Model> --view --force.
Or mark <plural>_index / <plural>_show #[command(live)] and read them
through live.* by hand.
Added
-
Change tracking on
Database— step 1 of RFC 0002 (live queries).db.changes().subscribe()receives the table of every model-layer write, once it's durable: the generatedinsert/update/delete, the bulk writes when a row changed, factories, and the pivot operations.db.touch(key)reports raw SQL or any other key ("settings:theme").- Inside
transaction, reports go out on commit, never on rollback. elyra::db::live::trackrecords the tables a future reads, including joins and relations;depends_ondeclares the rest. With a label, it refuses writes, which is what a live command's re-run will use.
See docs/database.md.
-
Live commands — step 2 of RFC 0002. The frontend can subscribe to a
#[command(live)]command, and Elyra re-runs it when what it read changes and pushes the new result to that window.- Subscribing:
POST /__live/<command>(guarded like/__cmd/, includingcan) answers{ id, value }. Updates arrive onelyra:live:<id>, andPOST /__live-stopends a subscription. - Re-runs: a write to a table the command read, or a
ctx.invalidate(key)of a key it declared withctx.depends_on, re-runs it through the middleware pipeline. Changes are coalesced over one frame (App::live_batch_window), and nothing is pushed when the result is unchanged. - Limits: a re-run that writes fails, and a window holds at most 64 subscriptions.
- Also new:
EventBus::emit_to, andTestApp::live/LiveHandlefor testing it.
- Subscribing:
-
live.*on the frontend — step 3 of RFC 0002. Codegen emits a typed store per#[command(live)](live.customers_index(query): LiveStore<Page<Customer>>), overlive()in@elyra/runtime.- The store's value is
Live<T>:{ value, error, loading }. A failed re-run keeps the last value. - The subscription opens with the first listener and closes with the last. An answer that arrives after everyone has left is stopped rather than leaked.
- The store's value is
-
make:resourcegenerates live views — step 5 of RFC 0002.- Commands:
<plural>_indexand<plural>_showare#[command(live)]. - Views:
Index.svelteandShow.svelteread fromlive.*. A customer added, edited or deleted in another window shows up without a reload, and the list's manual reload bookkeeping is gone. The form still reads once, so it never overwrites what's being typed. - Tests: the generated tests check that the list and the record update after a write.
- Smoke test: the browser harness's fake backend serves
/__live, and checks that another window's write reaches the list and the detail view.
- Commands:
-
The live queries guide — step 6 of RFC 0002, which is now implemented. The example app's todo list is a live query: open a second window, add a todo in either, and both lists update.
Fixed
- Regenerating a
--generated resource without--generatebroke it.make:resource <Model> --view --force(or--forcealone) on a resource made with--generaterebuilt the Rust half from the struct. The newmod.rsstopped declaring the model, migration and seeder, and the field list's formats,uniqueand references were lost. It's now refused, with the command to use instead: the same--generatefield list with--force.
0.8.0 — 2026-09-23
The resources release. rata make:resource writes the vertical slice a
desktop app needs for each table — a searchable, paged list, a validated form
and a detail view in Svelte, the ability-gated commands behind them, tests, and
with --generate the model, migration, factory and seeder — typed end to end
and using the framework's own safety rails. It stands on a new hash router in
@elyra/runtime and a registry that keeps main.rs to a one-time edit. See
the resources guide and
RFC 0001.
Also: validation messages in the app's language, $t / $tc typed against the
translation catalog, serde_json::Value through codegen, and single-instance
fixed on Windows.
Upgrading: nothing breaks. rata new now scaffolds a router
(Router.svelte, routes.js, pages/); existing projects keep their
App.svelte and can adopt it from the frontend runtime docs.
On Windows, single-instance uses new ports, so while updating, a 0.8 launch
won't hand off to a 0.7 instance that is still running.
Added
-
rata make:resource <Model>— step 3 of RFC 0001. For an existing#[derive(Model)]it generatessrc/resources/<name>/: the five resource commands (<plural>_indexwith search, an allowlisted sort and a capped page;_show,_store,_update,_destroy, soft-deleting when the model does), each behind an ability;<Model>Input/<Model>Query; validation rules from the field types, on create and update, translated when the app has aTranslator;Created/Updated/Deleteddomain events; andTestApptests for all of it. The model is read withsyn; missing derives or dependencies stop it with the exact lines to add, before anything is written.--dry-run,--force,--no-abilities. See docs/cli.md. -
rata make:resource <Model> --view— step 4 of RFC 0001: the Svelte screens for a resource inapp/src/resources/<plural>/— a list (debounced search, sortable headers, paging, delete behindconfirm()), one form for create and edit (a control per field type, validation messages per field) and a detail view — plus routes and a nav entry through the frontend registry. Labels go through$twhen the project haslang/en.json, and their English defaults are added to it as one key, leaving the rest of the file untouched. An existing Rust half is kept. See docs/cli.md. -
rata make:resource <Model> --generate <fields>— step 5 of RFC 0001: the model (with timestamps andimpl Factory), a reversibleRustMigrationand a seeder from a Rails-style field list (name:string email:email:unique 'phone:string?' active:bool=true team_id:references:Team), plus everything--viewmakes.references:addsbelongs_toand anexistsrule, and the seeder points rows at an existing parent. The generated tests run the real migrations, and coverunique(skipping the row itself on update) andexists. It refuses to shadow an existing model;--forceregenerates one it made, keeping the migration's version. Checked by migrating, seeding and rolling back on SQLite, MySQL 8 and Postgres 16. See docs/cli.md. -
Resource registries — step 2 of RFC 0001. A resource lives in
src/resources/<name>/andapp/src/resources/<plural>/, and rata gathers them in two registries it owns and regenerates from the folder listing (src/resources/mod.rs:commands()/migrations()/abilities();app/src/resources/index.js:routes/nav), somain.rsis wired once rather than per resource. rata prints the missing wiring and never editsmain.rs, and refuses to overwrite a registry without itsmanaged by ratamarker. Newrata resources:sync;rata newscaffolds the empty frontend registry, withroutes.jsand the nav reading it. See docs/cli.md. -
The resource registry gains
seeders(), run in migration order, so a parent is seeded before the resources that reference it. -
The resources guide — from
rata newto a working customer list withmake:resource, and what each generated file is for. RFC 0001 is implemented. -
scripts/smoke-resource.sh, run in CI on a fresh scaffold. It takesmake:resourcethrough the whole workflow:- the prerequisite refusal (nothing written);
--generateof a Team and a Customer referencing it, and the wiring hint;- the generated tests, clippy and fmt;
- a migrate, seed and rollback on SQLite;
- codegen and the frontend build;
- the views in headless Chrome against a fake backend.
A broken template now fails CI rather than a user. It runs locally too.
-
A router in
@elyra/runtime— hash-based (#/customers/12): aroutestore,navigate(withreplace),href,resolveRoute/matchRoutewith specificity-based precedence (/customers/newbeats/customers/:id; a*only wins when nothing exact matches), anddeepLinkPathforelyra:deep-linkURLs. Framework-agnostic;rata newnow scaffoldsRouter.svelte, aroutes.jstable with lazy-loaded pages, andApp.svelteas the layout. Step 1 of RFC 0001. See docs/frontend-runtime.md. -
Page<M>isSerialize/Deserializeand — with thedatabasefeature — aspecta::Type, so a command can return a paginated result directly and the frontend getsPromise<Page<User>>with a genericPage<M>inbindings.ts. -
Query::or_where_like(&columns, pattern)— anORgroup ofLIKEs across several columns,ANDed with the rest of the query: a search box. -
?convertsValidationErrorsand — withdatabase—elyra::db::Errorintoelyra::Error, so anelyra::Resultcommand can validate and query withoutmap_err. The bag keeps its JSON, so the frontend still gets aValidationErrorwith per-field messages. -
App::seeders(Vec<Box<dyn Seeder>>), for registering a list at once. -
Table::nullable_foreign_id— a foreign key to an optional parent. -
serde_json::Valuein commands and models. specta'sserde_jsonfeature is on, and codegen renders the type as a declaredJsonValueunion. Before, it failed codegen: specta can only inlineValue, it refuses to becauseValueis recursive, and the number policy never reached thei64insideNumber, which codegen now also coerces in any inline type. -
Translated validation messages.
Validator::translator(&t)renders each error fromvalidation.<key>in the app's translation files (e.g.validation.min.string) and names fields fromvalidation.attributes.<field>— by concrete path, then by wildcard pattern (items.*.name) — including:otherinsame/required_if. Untranslated keys fall back to the built-in English, which is byte-for-byte what it was (a 60-case golden diff against 0.7.0).validation::message_keys()lists all 59 keys; docs/validation.md has the full JSON skeleton. -
Translator::has(key). -
Typed translation keys. With an
I18nProvider,rata codegenemits aTranslationstype — every key in the fallback locale with the parameters its:placeholdersneed — andt/tc/translate/choicenarrowed to it, so$t("welcom")or$t("welcome")without{ name }is a compile error. Checked against real generated bindings withtsc, in both directions. See docs/i18n.md. -
Translator::codegen_keys()andcodegen::generate_all(generate_withplus a translation catalog).
Fixed
Option<bool>model fields couldn't be read back.boolwas mapped to an INTEGER0/1column, butOption<bool>went straight to theAnydriver, which has no boolean type:find/getfailed with "mismatched types … BIGINT". It now maps likebool, withNULLforNone— covered on SQLite, MySQL and Postgres. Found bymake:resource's generated tests.- Single-instance could silently fail on Windows. The primary instance
listened on one loopback port derived from the app name, in the dynamic range
where Hyper-V, WSL and Docker reserve blocks of ports (
netsh int ipv4 show excludedportrange) that nothing can bind. An app whose name hashed into such a block never became primary on that machine — a second launch opened a second window. It now has eight candidate ports spread across the range, binds the first that's free, and a second launch tries them in the same order (the handshake tells it when a port belongs to someone else). The port is also derived with FNV-1a instead ofDefaultHasher, whose output may change between Rust versions — so an update built with a newer toolchain can still find the instance already running. Found by a flaky CI test.
0.7.0 — 2026-09-23
The desktop-depth release: the rest of the Laravel gap, done the way a desktop app needs it. Commands take named middleware; the scheduler runs at clock times and survives the laptop sleeping through them; validation handles nested input, arrays and the database; and one set of translation files serves Rust and Svelte alike.
Upgrading: one change can affect existing code, under Changed below — an
unknown validation rule now panics instead of being ignored (a misspelt
"requried|email" used to accept every input), and the synchronous validate()
panics on unique / exists instead of skipping them. Fix the rule name, or use
validate_with(&db).await for database rules.
Added
- Per-command middleware —
#[command(middleware = ["auth", "audit"])], withApp::middleware_alias(name, mw)andApp::middleware_group(name, [..]). Laravel's middleware aliases and groups: named middleware runs only for the commands that ask for it, inside the global stack, in declared order; groups expand in place and a middleware reached twice runs once. An unknown name or a group cycle stops the app at startup, so a misspelt name never means a command runs without its middleware. See docs/middleware.md. - Clock-time scheduling —
Scheduler::daily_at("09:00"),weekdays_at,weekly_on,monthly_on,hourly_atand five-fieldcron("*/15 9-17 * * mon-fri")expressions, in the system time zone. Built for a desktop: a machine that sleeps through a job's time runs it once on wake (clock jobs re-check the wall clock at least every 30 s rather than trusting one monotonic timer), DST gaps and folds are handled, and a job's runs never overlap. Time zones come fromjiff(system tzdb; bundled on Windows). See docs/scheduler.md. - Validation: nested fields, arrays and 30 more rules. Fields may be paths
(
address.city,items.*.name, errors keyeditems.1.name), with wildcard sibling references (required_if:items.*.kind,physical) anddistinctacross a wildcard. New rules:required_if/_with/_without,accepted,filled,array,uuid,ip,alpha/_num/_dash,digits,digits_between,between,gt/gte/lt/lte,not_in,starts_with,ends_with,regex(viaregex-lite),date,before/after(+_or_equal).unique/existscheck the database throughValidator::validate_with(&db).await, includingunique:users,email,{id}for edit forms.Validator::rule_listtakes rules as a list, for patterns with|. See docs/validation.md. - Translations (i18n) —
lang/<locale>.jsoncatalogs (nested or flat), embedded withI18nProvider::embedded::<Lang>(). In Rust,Translator::get/choice/set_locale; in Svelte,$t("welcome", { name }),$tc("files", n),$localeandsetLocale, which re-render when the locale changes anywhere. Lookup runsnb-NO→nb→ fallback, a missing key returns the key, placeholders follow:name/:Name/:NAME, and plurals use Laravel's{0}/[2,*]conditions and per-language rules (one form in Japanese, three in Russian and Polish) on both sides. The starting locale is the one the user last chose (saved in theStore), else the OS language (viasys-locale:$LANGis unset for a Dock-launched macOS app), else the fallback. See docs/i18n.md. Store::at(path)andStore::fake()(in memory, never written) — so a test needn't write into the real app-data directory.
Changed
- An unknown validation rule panics instead of being ignored, naming the rule
and the field —
"requried|email"used to pass every input. The synchronousvalidate()likewise panics onunique/existsrather than skipping them.
Fixed
- 23 broken intra-doc links — public docs linking to private items (some
from the 0.5.8
shell/split), an ambiguouscrate::errormodule/macro, andAbout <App>read as an HTML tag.cargo docnow runs with-D warningsin CI so new ones fail the build instead of piling up.
Testing
- The durable-queue tests simulate real restarts. Two of them failed intermittently on the slower Linux and Windows runners after 0.6.0 (the pull request runs happened to pass): the "restart" never stopped the first app, so two instances worked one journal at once, and a delay test assumed startup took under 400 ms. Each launch now runs in its own tokio runtime that is shut down afterwards, and the delay test asserts the invariant rather than a deadline.
0.6.0 — 2026-09-22
The Laravel-depth release. The model layer gains the features that most often sent code back to raw SQL — many-to-many, casts, scopes, factories — the container binds traits and builds services from their dependencies, domain events connect one part of an app to another (and, with one call, to a typed frontend channel), the queue can survive a restart, and tests get Laravel's fakes.
Upgrading: two changes can affect existing code, both under Changed and Fixed below.
#[derive(Model)]now rejects#[model(..)]options it doesn't recognise (they used to be ignored silently), so a stray or misspelt option stops the build at that line.Model::find/Model::allnow respect#[model(soft_deletes)]and global scopes, like every other query. Code that relied on them returning trashed rows should useModel::query().with_trashed().
Added
-
belongs_to_many— many-to-many through a pivot table, with Laravel's naming defaults (role_user,user_id,role_id) and overrides (pivot,fk,related_fk,as). Generatesroles(),roles_query()(aQueryto keep narrowing, count or paginate),load_roles(),attach_roles(),detach_roles(),detach_all_roles(),sync_roles()andsync_roles_without_detaching().syncruns in one transaction and returns theSyncChangesit made. Eager loading is one query — the pivot key is selected with the related columns — so the related model needn't beClone. Also available on a field (with_<field>hydration). See docs/models.md. -
Casts —
#[model(cast = ..)]."json"stores any serde type as JSON text (NULL⇄None),"text"anyDisplay + FromStrtype (enums), and a path names your ownimpl Cast<T>. A value that won't decode is an error naming the column. -
Local scopes —
Query::scope(f)for anyfn(Query<M>) -> Query<M>, pluswhen(condition, f)andwhen_some(option, f)for optional filters. -
Global scopes —
#[model(global_scope = path)], repeatable. Applied to every read and bulk write for the model, includingfind,alland relation queries into it;Query::without_global_scopes()opts out explicitly. -
Model factories —
impl Factory for User { fn definition(n: u64) -> Self }, thenUser::factory().count(3).state(..).sequence(..).create(&db)/create_one/make/make_one.nis unique process-wide, so unique columns survive repeated builders.Factoryis exported fromelyraand the prelude. -
Query::find(db, id), soModel::query().with_trashed().find(..)reaches a trashed row;Valueconverts fromi16,f32,&StringandOption<T>. -
Trait-object bindings —
bind_as::<dyn Mailer>(Arc::new(SmtpMailer))onContainerandApp, resolved withctx.get::<dyn Mailer>(). Laravel'sbind(Interface::class, Impl::class); also what makes thesubstratecontracts bindable.get,try_getandTestApp::getaccept trait objects. -
Lazy singletons —
bind_lazy(|ctx| ..), built on first resolution with a fullCtx, so a provider'sregistercan bind a service that depends on another provider's binding. A dependency cycle panics with the chain of types rather than deadlocking. PlusContainer::has/Ctx::has. -
Domain events —
Dispatcher.App::listen(|e: OrderShipped, ctx| ..)orctx.get::<Dispatcher>().listen(..)from a provider, thenctx.dispatch(event).await. Listeners run in registration order; the first error stops the chain and is returned to the dispatcher.ctx.dispatch_background(event)fires and forgets, logging failures. See docs/events.md. -
App::broadcast::<E>("channel")forwards a domain event to the frontendEventBusand declares the channel's type for codegen in one call — a typed path fromctx.dispatch(..)in Rust tochannel(..)in Svelte. -
Durable queues —
QueueProvider::durable()(featuredatabase). Jobs are kept in the app's database (elyra_jobs/elyra_failed_jobs, created on first use) until they succeed or finally fail — Laravel'sdatabasedriver. Pending jobs, the waiting backlog, delays and retry backoff survive a restart and resume where they were; failed jobs are listed again on the next launch and can be retried. Delivery is at least once. See docs/queue.md. -
Queue::push_confirmed(..).await— enqueue and return only once the job is durable (plainpushstill reports a full queue synchronously and writes the row a moment later).Queue::is_durable(). -
Test fakes —
Queue::fake(),Storage::fake(),Dispatcher::fake(). Laravel'sQueue::fake()/Storage::fake()/Event::fake(): record pushes, use a private temp disk, record events without running listeners — each withassert_*helpers whose failures say what did happen and point at the test line. See docs/testing.md. -
App::swap/App::swap_as— replace a binding after every provider has registered (and after the framework's own bindings), so a fake wins over the production wiring without editing it. Laravel's$this->swap().
Changed
-
#[derive(Model)]rejects unknown options. It used to discard anything it didn't recognise — including parse errors — so a misspeltglobal_scopewould have compiled into a model that silently leaked across tenants. Unknown keys, malformed relations and non-identifiertable/columnnames are now compile errors.Upgrading: a build that carried a meaningless
#[model(..)]option stops compiling at that line; remove or correct it. -
Bulk
updatebinds itsSETvalues and constraints into one argument list, replacing a step that re-derived theWHEREbindings separately (a precondition for global scopes, which add constraints the old path didn't know about). -
The container stores
Arc<T>behinddyn Any(rather thanT), which is what letsTbe unsized.bind/getbehave exactly as before. -
A durable queue's recovered jobs are delivered once every provider has booted rather than in
QueueProvider::boot, so handlers registered by a later provider receive them. A recovered job with no handler moves to the failed table instead of being dropped. In-memory queues behave as before.
Fixed
-
The
rata newsmoke test in CI failed to build the scaffolded frontend (failed to resolve import "@elyra/runtime"):--elyralinks the runtime to the checkout'sruntime/as afile:dependency, whose entry point is the unbuiltdist/. CI now builds the runtime first, andrata new --elyraprints that step whendist/is missing, since a fresh local clone hits the same wall. -
findandallreturned soft-deleted rows. Both issued raw SQL that bypassed the builder, so#[model(soft_deletes)]only applied toquery(). They now go through the builder, which also makes them respect global scopes — otherwise the new scopes would have had the same hole. Abelongs_tolookup, which callsfind, no longer returns a trashed owner. -
Aggregates dropped joins.
count/sum/avg/min/max— and sopaginate's total — cleared a query's joins, so any constraint on a joined column produced invalid SQL. Joins are kept now.
Testing
tests/eloquent.rs(12 tests) covers casts, local and global scopes on reads and bulk writes, pivots, factories and the soft-delete fix on SQLite;tests/model_servers.rsgains casts, a globally-scoped bulkUPDATE(the$nnumbering acrossSETandWHEREon Postgres) and a pivot round trip on real MySQL 8 and Postgres 16.
0.5.8 — 2026-08-29
A security + structure release. The last all-or-nothing grant in the
capability model is gone — individual commands can now be gated with
#[command(can = "…")], so a script that reaches the page no longer reaches
every command an app registers. Secrets are wiped from memory instead of being
left in freed allocations, a 403 finally says which gate refused, and the
1 887-line shell.rs is split so the access-control decision is auditable on
its own.
Upgrading: two changes need attention, both under Security below —
Secrets::get returns a Secret instead of a String (feature secrets), and
#[command] now rejects an attribute it previously ignored. See
docs/security.md for the extended model.
Security
-
Per-command abilities —
#[command(can = "…")].Capability::Commandsis one grant covering all of/__cmd/*, so a single XSS reached every command an app registered. A command can now declare an ability, which the app must grant explicitly:#[command(can = "posts.delete")] async fn delete_post(ctx: Ctx, id: i64) -> elyra::Result<()> { /* … */ } App::new().allow_ability("posts.delete"); // or .allow_abilities([..]) / "posts.*"Deny-by-default: without the grant the call answers
403naming the missing ability. Commands withoutcan = ..are unchanged, so this is opt-in per command. Rust-side dispatch (providers, queue jobs,TestApp) is never gated — the same split asSidecar::spawnvs. frontend sidecar spawn. See docs/security.md. -
#[command]validates its attribute. The attribute token stream used to be ignored outright, so#[command(anything)]compiled silently. Unknown options and malformed abilities are now compile errors. -
ForbiddenErrornow reports which gate refused. A 403 always read as "missing or invalid IPC token", so an ungranted capability — and now an ungranted ability — sent you looking in the wrong place. The runtime reads the shell's explanation into the message and exposes it aserror.detail. -
Secrets::getreturns aSecret, not aString(featuresecrets). The value wipes its bytes on drop viazeroizeinstead of leaving the plaintext in a freed allocation, and itsDebugrendersSecret(***)so a stray log line can't leak it.Upgrading:
Secretderefs to&str, sosecrets.get(k)?.as_deref(),&*secretandsecret.expose()all work; only code that bound the result as an ownedStringneeds.to_string()(which escapes the wipe — prefer passing the&str).get_or_migrate_envchanges the same way.
Added
elyra::prelude—App,Ctx,Container,Provider,Result,EventBus, the middleware types,command+commands!, and (behinddatabase)Model/Query/Database, in one glob import.rata make:middleware <name>— scaffolds aMiddlewareimpl and prints the.middleware(..)wiring step, completing themake:*set.aiis documented in the README feature table (the feature itself shipped in 0.4.0).
Changed
-
shell.rssplit into ashell/module. 1 887 lines that mixed protocol, routing, access control and the webview lifecycle are nowguard(token, capabilities, rate limits, body limits, CORS, CSP),protocol(the wire shapes),router(dispatch),facades,update,assetsandwebview. Purely internal —shellis a private module, so no public API moved — but the access-control decision is now auditable on its own, with 8 new unit tests overguard::checkthat the old layout made awkward to write. -
Frontend toolchain moved to the current major line. The
rata newtemplate and the example app now scaffold Vite 8,@sveltejs/vite-plugin-svelte7 and Svelte 5.56 (from Vite 6 / plugin 5 / Svelte 5.20). The runtime package builds on TypeScript 7 and tests on Vitest 4.Upgrading: these raise the Node floor to
^20.19 || >=22.12, declared in both@elyra/runtimeand generated apps. Existing projects keep working on their pinned versions; to move, bump the three devDependencies together — plugin-svelte 7 requires Vite 8 and Svelte >=5.46 as peers. -
Dependency refresh across the workspace lockfile (tokio 1.53, thiserror 2.0.20, ureq 3.4, tray-icon 0.24.2 and friends). No API changes.
Testing
- The frontend runtime went from 9 tests to 56.
src/index.test.tsis split intoinvoke,channel,systemandtokensuites over a sharedtest-support.ts, covering the command framing and header contract, every error kind (command/validation/panic/forbidden/unknown), the cancellable path, the event pump's reconnect, backoff and give-up-on-403 behaviour, the snake_case/__sys/*payloads, and the no-token document. - The IPC gate got its own unit tests. Eight cases over
guard::check— a missing and a forged token, a granted route, an ungranted capability, an oversized body, and the four ability outcomes — plus six end-to-end ability tests through the realroute()pipeline intests/shell.rs.
0.5.7 — 2026-07-29
A hardening + Laravel-parity release. The IPC surface is now gated by a
per-run token, an origin rule and a capability model; the event bus, the settings
store, the cache and the queue got the durability they were missing; and the
Laravel-shaped pieces that were absent — Log, Config, Secrets, TestApp, a
schema builder, pagination/aggregates/joins/soft deletes/transactions — landed.
Upgrading: four behaviour changes need attention, all listed under
Changed below (IPC token for hand-written frontends, opt-in capabilities for
destructive routes, sidecar_allow for frontend spawn, and the new Asset
shape). See docs/security.md for the new model.
Security
- IPC token + origin-scoped CORS.
Access-Control-Allow-Origin: *was sent on every response, including/__cmd/*,/__sidecar/*,/__sys/*and/__storage/*— any origin able to reach the protocol had full access to commands, the filesystem, the clipboard and process spawning. Production builds now send no CORS headers at all,rata devgets CORS for the exactELYRA_DEV_URLorigin only, and every/__*request must carry this run's random IPC token (injected into the webview asglobalThis.__ELYRA__.token, attached automatically by@elyra/runtimeasx-elyra-token). Rejected requests get403and surface asForbiddenErroron the frontend. - Capabilities for the frontend. Each
/__*route now maps to asecurity::Capability. The everyday ones are granted by default;StoreClear,CacheFlush,StorageDeleteandUpdaterInstallare opt-in viaApp::allow_frontend(..), anddeny_frontend(..)revokes any of them. The expensive ones are also rate-limited per window. - Sidecar spawn is deny-by-default.
/__sidecar/spawnaccepted any program + args from the frontend (arbitrary code execution from a single XSS). The frontend may now only spawn programs named viaApp::sidecar_allow(..); Rust-sideSidecar::spawnis unchanged. shell.openis policy-gated. Onlyhttp/https/mailto(plus schemes added withApp::allow_open_schemes(..)) are handed to the OS;file:URLs, relative/non-existent paths, and anything executable (.exe,.sh,.app,.desktop, …) are refused.App::deny_open_paths()blocks local paths too.- A strict CSP by default.
security::DEFAULT_CSPis served with every HTML response unless overridden withApp::csp(..)or disabled withApp::csp_disabled(). - Request bodies are bounded and depth-checked. 16 MiB by default
(
App::max_request_body), and MessagePack nested deeper than 64 levels is rejected before it reaches serde's recursive deserializer (a ~10 KB body could overflow the stack and abort the process). - Single-instance handshake is authenticated. The rendezvous moved from a
loopback TCP port with a guessable magic string to a
0600Unix socket (TCP on Windows) gated by a random per-install token, and forwarded deep links are now parsed and validated instead of prefix-matched. - Updater hardening. HTTPS is required for the manifest and the artifact
(loopback excepted,
allow_insecureto opt out), the download streams to disk with amax_artifact_bytescap instead of buffering in memory, and a failed signature check leaves nothing staged. - Secrets. New
secretsfeature:Secretsstores tokens in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) instead of the clear-text settings file, withget_or_migrate_envto move off env vars. - New
docs/security.md: the IPC surface, the three gating mechanisms, dangerous operations, and a release checklist.
Added
Logfacade. Levels, targets, ISO-8601 timestamps, a rotating file sink (LogProvider::to_app_dir),ELYRA_LOGfor runtime control, andlog_path()for a "send us your log" button. Every command dispatch is traced with its duration; the framework'seprintln!s are gone.Configlayer.elyra.toml,config/*.toml,.envand process env merged into one dotted-key map with${VAR}expansion, typed accessors andsection(). Bound viaConfigProvider.- Test facilities.
elyra::testing::TestAppinvokes commands through the real middleware pipeline without a window (invoke,invoke_ok,invoke_err,invoke_validation_errors,events_on,assert_emitted), andTestShelldrives the actual/__*routing for IPC-level tests. - Schema builder.
elyra_db::Schema::create/table/drop/renamerenders DDL per driver (SQLite/MySQL/Postgres), withRustMigrationfor migrations written in Rust andrata make:migration --rustto scaffold them. - Query-builder parity.
where_like/where_null/where_not_null/where_between/or_where_eq, chainedorder_by,offset,join/left_join,count/sum/avg/min/max/exists,paginate(returningPage),chunk, bulkupdate/delete, soft deletes (#[model(soft_deletes)]+with_trashed/only_trashed/restore), andDatabase::transaction/begin. - Seeders.
App::seeder(..)+ELYRA_SEED=1; Rust migrations run withELYRA_MIGRATE=up|down. - Queue maturity. Retries with exponential backoff, per-job
JobOptions(attempts / backoff / timeout), a failed-jobs list withretry_failed,push_later, typeddispatch/on_typed, bounded capacity with backpressure, and multiple workers (QueueProvider::with_workers). - Typed event channels + bigint codegen.
App::event::<T>("channel")makesrata codegenemit anElyraEventsmap and a narrowedchannel();App::codegen_bigint()exports 64-bit integers asbigint. - Asset caching + ranges. Embedded assets are served borrowed (no per-request
copy) with
ETag,304handling,immutablecaching for fingerprinted files andRange/206support for media. - Cross-platform app menu.
App::menu(..)now renders on Windows and Linux (per-window menu bar via muda), not just macOS. - Bundling beyond macOS.
rata bundlebuilds a.deb(nodpkgneeded) plus a portable.tar.gzon Linux and a portable folder on Windows, and the macOSInfo.plistnow registersCFBundleURLTypesfrom[bundle].deep_link. @elyra/runtimeis publishable. Built todist/with.d.ts, anexportsmap,files, 9 vitest tests, and a provenance publish workflow.- Pool tuning.
DatabaseOptions(max connections, acquire timeout, idle / lifetime) and SQLiteWAL+busy_timeout+foreign_keysby default. - CI. Rust matrix across macOS/Linux/Windows, an MSRV (1.80) job,
cargo deny(advisories / licenses / bans / sources), arata newbuild smoke test, and runtime typecheck + tests + build.
Fixed
- Events reached only one window. The event bus kept a single shared queue, so
with several windows whichever one polled first took the batch and the others
silently lost the events. Each webview now identifies itself
(
x-elyra-client-id) and gets its own queue; anemitfans out to all of them. New:EventBus::next_batch_for,disconnect,client_count. - A panicking command hung the frontend forever. Non-cancellable commands were
dispatched inline in the task owning the protocol responder, so a panic (or a
missing container binding, which panics by design) dropped the responder without
a reply and
await invoke(..)never settled. Commands now always run on their own task; a panic becomes a500withx-elyra-error-kind: panic. - Structured errors were unusable.
Error::Commandprefixed every message with"command failed: ", so aValidationErrorsbag arrived ascommand failed: {"email":[…]}and could not be parsed — the documented validation flow was broken end to end. Messages are now verbatim, the response carriesx-elyra-error-kind, and the runtime throws a typedValidationErrorwith a parsederrorsbag. rata newproduced a project that couldn't build. The scaffold pinnedelyra = "0.1"(against a 0.5.x API) and@elyra/runtime^0.0.0; both now use the CLI's own version.- Store writes were neither atomic nor coalesced.
settings.jsonis now written to a temp file and renamed (with a.bakfallback on read), and bursts ofset()are debounced off the IPC thread instead of writing per call. - Cache was unbounded. Entry-count and byte budgets with LRU eviction
(
Cache::with_limits), and TTLs now use the wall clock as well as the monotonic one, so a deadline no longer freezes while the machine sleeps. - RateLimiter could overshoot its limit.
attempt()was a check followed by a separate increment; it now uses one atomicCache::increment_if_below. where_inand joins. Qualifiedtable.columnidentifiers are accepted (and validated) so joined columns can be filtered and ordered.
Fixed (post-tag)
- The cross-platform app menu broke non-
traybuilds off macOS.ABOUT_MENU_IDandUserEvent::MenuClickwere gated onany(target_os = "macos", feature = "tray"), but the new per-window menu references them unconditionally — socargo build --features databasefailed to compile on Linux and Windows. Both are now unconditional. Caught by the new CI matrix and therata newsmoke test. - Internal crates now carry a
versionalongside theirpath, whichcargo publishrequires andcargo deny's wildcard check flagged. - SQLite URLs built from a filesystem path were broken on Windows. The obvious
format!("sqlite://{}", path.display())produces a drive colon and backslashes where the URL grammar expects an authority and forward slashes, so SQLite answeredunable to open database file. Newelyra_db::sqlite_url(path)(andsqlite_url_opts) build it portably; the model tests now use it. This affected any app deriving its database path at runtime, not just the tests. - Linux builds now need
libxdo(the cross-platform app menu links muda); documented in the getting-started prerequisites. rata newscaffolded a project that still couldn't build. Correcting the version strings wasn't enough: Elyra isn't published to crates.io or npm, soelyra = "0.5.7"and@elyra/runtime@^0.5.7named packages that don't exist. The scaffold now depends on the matching tagged GitHub release — a git dependency for the crate, and the@elyra/runtimetarball attached to the release for the frontend (npm accepts a tarball URL but cannot install a git subdirectory).
Changed
AssetcarriesCow<'static, [u8]>plus anetag(wasVec<u8>+ mime only).CacheProviderandQueueProviderare now constructed (CacheProvider::new()/::with_limits(..),QueueProvider::new()/::with_workers(..)) instead of being unit structs.Queue::pushreturnsbool(falsewhen the queue is full) and the frontend'squeue.pushreports a full queue as an error instead of silently dropping.- Frontends that talk to the bridge without
@elyra/runtimemust now sendx-elyra-token(andx-elyra-client-idfor/__events); the token is exposed to page scripts asglobalThis.__ELYRA__.token. @elyra/runtimeships built output. The package now exportsdist/with.d.tsfiles instead of rawsrc/*.ts; bundlers other than Vite work as a result, and the version tracks the crate (0.5.7).elyraand@elyra/runtimeare both at 0.5.7;rata newpins the CLI's own version instead of a hard-coded one.- MSRV is now declared as 1.94 (was 1.80). The old value was never verified —
the new CI job showed it can't build at all, since sqlx 0.9 requires 1.94 and
several dependencies need
edition2024(Cargo 1.85+). Nothing regressed; the declaration was simply wrong.
0.5.6 — 2026-07-29
Added
- Rate limiter. A cache-backed
RateLimiter(Laravel-RateLimiter-style) —too_many_attempts/hit/attempts/remaining/clear/attempt, with self-expiring per-key counters. Get one viaCache::limiter(). - Task scheduler. A Laravel-
Schedule-styleSchedulerfor recurring background jobs —every/every_minutes/hourly/daily, async closures, bound viaSchedulerProvider. Interval-based (from app start), in-process; registration works before or after start. - Artisan-style generators.
rata make:command,make:provider, andmake:modelscaffold a source file undersrc/(with normalized snake/Pascal names and pluralized model tables) and print themod+ registration wiring step. Existing files are never overwritten. - Validation. A Laravel-style validator (
elyra::validation): check command input against a rule string ("required|email|min:18") and get a per-fieldValidationErrorsbag. Return it via?from a command and the frontend reads the structured errors withvalidationErrors(err)from@elyra/runtime. Rules:required,nullable,string,integer,numeric,boolean,email,url,min,max,size,in,same,confirmed. Core, no deps.
0.5.5 — 2026-07-25
Fixed
-
The bundle updater rejected every update. 0.5.4 verified the downloaded bundle with
codesign --verify --strict --quiet— butcodesignhas no--quietflag, so it exited 2 with "unrecognized option" on every run and the updater concluded that no update was correctly signed. Auto-update was dead on arrival for bundled apps: safe (nothing was installed) but useless.The verification now lives in its own function, carries codesign's own reason into the error message, and is covered by a positive test: a correctly signed bundle must be accepted, both freshly signed and after the
dittoround-trip the updater performs. Rejection tests alone could not catch this — a broken invocation rejects everything, which looks exactly like a working guard.
0.5.4 — 2026-07-25
Fixed
-
The auto-updater no longer breaks a signed macOS
.app.Updater::apply_and_relaunchreplaced only the running executable. Inside a code-signed bundle that is fatal: the signature sealsInfo.plistand every file underContents/, so dropping a new binary in — and leaving the.oldbackup beside it — broke the seal, and macOS then refused to launch the app at all with "The application can't be opened." The app had to be reinstalled by hand.The updater now detects that it is running inside
Foo.app/Contents/MacOS/and switches to whole-bundle replacement: the artifact must be a zip of the signed.app, which is expanded withditto(preserving extended attributes and the signature), verified withcodesign --verify --strict, checked to carry the sameCFBundleIdentifier, and only then swapped in with a rename inside the bundle's own directory — with the outgoing copy moved outside the bundle and a roll-back if the swap fails. Relaunch goes throughopenso LaunchServices re-registers the new bundle.A bare-binary artifact offered to a bundled app is now refused with an explanation instead of applied. Loose (unbundled) executables keep the previous in-place swap.
Releasing note: apps distributed as a signed
.appmust publish a zip of the bundle as the update artifact. A release that keeps publishing the bare executable will now be rejected by the client rather than installed.
0.5.3 — 2026-07-24
Added
rata bundleapp icon. The macOS bundle now generates the native dock/Finder icon: it renders the source image toContents/Resources/AppIcon.icns(sips+iconutil; SVGs rasterized at 1024 viaqlmanage,sipsfallback) and setsCFBundleIconFile. Configure with[bundle].icon, or it auto-detectsapp/public/icon.svg(shipped byrata new),icon.png, etc. Best-effort — the bundle still builds without it.
0.5.2 — 2026-07-22
Added
- Command cancellation.
invokeCancellable(command, ...args)in@elyra/runtimereturns{ id, result, cancel };cancel()aborts the in-flight command on the Rust side (via a request-id header + a/__cancelroute that aborts the command's task). Progress is done with the event bus (documented pattern) — no new API needed. - AI rate limiting + token budget.
AiBuilder::rate_limit(per_minute)throttles every provider call (waits, doesn't error);token_budget(max)refuses new prompts once cumulative tokens hit the cap (Error::Budget);Ai::tokens_used()reports the running total. - Opt-in CSP.
App::csp(policy)sets aContent-Security-Policyheader on HTML responses served overelyra://(off by default — a too-strict policy can break the webview).
Changed
- Locks no longer poison. Switched internal
std::sync::Mutextoparking_lot::Mutexacross the cache, event bus, sidecar, store, queue, windows, and the AI client — a panic while holding a lock can no longer cascade into a poisoned-lock crash.
Fixed
- Sidecar CPU spin. If every command sender dropped while a child was still
running, the owning task's
select!busy-looped on a closed channel at 100% CPU. The command arm is now disabled once the channel closes; the task only waits on the child to exit. - Unbounded EventBus growth. Emitted events buffered without limit when the
frontend was gone/reloading/slow. The queue is now capped (
MAX_QUEUED); when full the oldest half is dropped so a reconnecting frontend still gets recent state. - Cache TTL leak. Expired entries were only reclaimed when the same key was
read again.
CacheProvidernow starts a background sweeper (Cache::sweep) that drops expired entries periodically; it holds aWeakref and stops when the cache is dropped. - Predictable updater temp files. Downloaded updates were written to a static
path in the temp dir. They now use an unpredictable filename, refuse to open a
pre-existing path (
O_EXCL), and are created0600on Unix — mitigating symlink attacks and collisions on shared machines.
Changed
- Migrations run in a transaction. Each migration (and its history row) now commits atomically where the driver supports transactional DDL (SQLite, Postgres); a mid-file failure rolls back cleanly. MySQL auto-commits DDL, so partial state there remains possible — documented.
0.5.1 — 2026-07-17
Added
substrate-corecrate. A tiny, dependency-free crate defining the shared, backend-agnosticCache/Storage/Queuecontracts behind the "one ecosystem" facades — the same traits the Askr/Laravel side can implement. Elyra'sCache,Storage, andQueuenow implement them (re-exported aselyra::substrate); conformance is verified intests/substrate.rs.Cacheis byte-internal, sosubstrateget/putround-trip losslessly.
Changed
Cachestores values as bytes internally (the JSON/typed API is unchanged sugar on top). No behavior change for existing callers.
0.5.0 — 2026-07-17
Added
- Queue facade. An in-process background job queue with the same surface as
Laravel's
Queue::—pusha named job, register an async handler withon. Jobs run in order on a background task; status is emitted onelyra:queue(onQueue). Bind withQueueProvider; push from Rust (ctx.get::<Queue>()) or the frontend (queuein@elyra/runtime). In-process / non-durable by design (durable, cross-process queues are Askr's domain). - Storage facade. A filesystem disk with the same surface as Laravel's
Storage::—put/get/append/exists/delete/size/files/url, every path jailed to the disk root. Bind withStorageProvider::at(root); use from Rust (ctx.get::<Storage>()) or the frontend (storagein@elyra/runtime). - Cache facade. An ergonomic in-process, TTL-aware key-value cache with the
same surface as Laravel's
Cache::(and Askr's shared cache) —get/put/add/remember/increment/forget/flush, typed helpers, arbitrary JSON values. Bind withCacheProvider; use from Rust (ctx.get::<Cache>()) or the frontend (cachein@elyra/runtime). First of the shared "one ecosystem" facades that mirror the Askr/Laravel side over a local backend.
0.4.0 — 2026-07-15
Added
- AI reliability (
ai). Automatic retries with exponential backoff on transient failures / retryable statuses (AiBuilder::retries/retry_backoff), provider failover (Chat::failover([...])), and in-memory response caching for plain prompts (AiBuilder::cache/cache_ttl,clear_cache). - AI provider tools (
ai). Native, server-executed web search and web fetch (web_search/web_fetchonChat,WebSearch/WebFetch/UserLocation). Anthropic-native; OpenAI returnsUnsupported(Responses API not used yet). - AI audio (
ai). Text-to-speech (ai.speech(...).generate()→GeneratedAudio) and transcription (ai.transcribe(bytes, name).generate()), over OpenAI (gpt-4o-mini-tts/whisper-1defaults).
0.3.1 — 2026-07-14
Added
- AI SDK (
aifeature). A new Laravel-inspiredelyra-aicrate, re-exported aselyra::ai: anonymous + named agents (Agent), tools with an automatic tool-use loop (Tool), sub-agents (anAgentused as a tool viasub_agent/AgentTool), structured output (prompt_as::<T>viaserde+schemars), streaming (stream→StreamChunk, ideal for the event bus), images, embeddings, and an in-memory vector store for RAG (VectorStore+cosine_similarity) over Anthropic + OpenAI.AiProviderbinds an env-configuredAiclient into the container. Default text modelclaude-sonnet-5; imagesgpt-image-1. - Single-instance (
App::single_instance). Later launches focus the running window and forward their command line onelyra:second-instance(onSecondInstance), then exit. Portable loopback rendezvous with a per-app handshake. - Deep-linking (
App::deep_link("myapp")). Launch URL viadeepLink.initial(), later URLs onelyra:deep-link(onDeepLink) — macOS open-URL event + Windows/Linux scheme registration; pairs with single-instance for while-running delivery.
0.3.0 — 2026-07-14
Added
-
Sidecar processes (
sidecarfeature). Spawn and manage child processes viasidecarin@elyra/runtime(spawn/write/kill) or theelyra::sidecar::Sidecarhandle;stdout/stderrlines and exit stream on theelyra:sidecarchannel (onSidecar). No extra crate — usestokio. -
Autostart (
autostartfeature). Launch the app at login viaautostartin@elyra/runtime(enable/disable/isEnabled) or theelyra::autostartmodule. Backed byauto-launch(LaunchAgents / registry /.desktop). -
Settings store. A persistent key-value store (
storein@elyra/runtime,Storein the container) backed bysettings.jsonin the OS config dir —get/set/delete/all/clear, arbitrary JSON values. Core, no feature flag. -
Native application menu.
App::menu(Menu::new().submenu(Submenu::new("File")…))adds custom submenus (with accelerators) after the standard app + Edit menus; clicks emitelyra:menu(subscribe withonMenu). Rendered on macOS. -
Global shortcuts (
shortcutsfeature).App::global_shortcut("CmdOrCtrl+Shift+P")registers OS-level keyboard shortcuts; firing one emits theelyra:shortcutevent (subscribe withonShortcut). Backed byglobal-hotkey. -
Window-state persistence.
App::persist_window_state()remembers the primary window's size, position, and maximized state between runs (stored under the OS config directory, keyed by the About name). Dependency-free. -
Window control + file drop.
@elyra/runtimeexportsappWindow(minimize / maximize / fullscreen / close / focus / show / hide / center / setTitle / setSize) with live state viaappWindow.onState, andonFileDropfor native file drops. Backed by newWindowsmethods on the Rust side (usable from commands, with an optional target-window label). Core — no feature flag. -
UI components in
@elyra/runtime. Themed, dependency-free primitives:alert/confirm/promptdialogs,toast()notifications, a ⌘K command palette (registerCommands/openCommandPalette), andcontextMenu(). They read the app's CSS variables, matching the About / update components. -
System integration (
systemfeature). Native desktop essentials exposed through@elyra/runtime: file dialogs (dialog.open/dialog.save), opening URLs/files in the OS (shell.openExternal), the clipboard (clipboard.readText/writeText), OS notifications (notify), and standard paths (paths). Backed byrfd,open,arboard,notify-rust, anddirs; also usable from Rust via theelyra::systemmodule.
0.2.0 — 2026-07-13
Added
-
Models — relation auto-hydration. Declare a relation on a field (
#[model(has_many(Book, fk = "author_id"))] books: Vec<Book>) and the derive skips it as a column, defaults it to empty, and generates awith_<field>batch hydrator that fills it in one query — no more joining aHashMapby hand. Works forhas_many(Vec<T>),has_one/belongs_to(Option<T>;belongs_totargets must beClone). -
Models — non-
i64primary keys. A single-column primary key may now be any type (e.g.String), marked with#[model(id)]. The value is app-supplied and included in theINSERT(no key read-back), andfindtakes that key type. The defaulti64autoincrement behaviour is unchanged. Composite keys remain unsupported. -
Models — column-aware
belongs_to. The owning row is looked up against the related model's actual primary-key column (<T>::PK) instead of a hardcodedid, and the child's foreign key is read by column name — sobelongs_toworks even when the owner's PK column is renamed via#[model(column = "..")]. -
Codegen: serde container attributes are now reflected in the generated TypeScript via
specta-serde—rename/rename_all, tagged and untagged enums (as discriminated unions),flatten(as intersections), andskip. Elyra's numeric policy (64-bit ints and floats render asnumber) is applied on top. -
Database tests: model CRUD now runs against real MySQL and Postgres servers in CI (
model_servers.rs), exercising per-driver placeholders (?vs$n) and key retrieval (last_insert_idvsRETURNING). The tests are opt-in viaELYRA_TEST_MYSQL_URL/ELYRA_TEST_POSTGRES_URLand skip cleanly when unset.
Changed
- Updater:
UpdaterConfig::auto_checknow defaults tofalse. The silent startup check (and its toast) is opt-in via.auto_check(true), so apps no longer notify about updates on launch unless they ask to. - Dependencies: updated to their latest releases —
sqlx0.9 (dynamic SQL is now wrapped inAssertSqlSafe),ureq3,ed25519-dalek3,tray-icon0.24 +muda0.19, and acargo updateacross the tree. No public API changes. - Tooling: CI typechecks the runtime with TypeScript 7 and runs on
Node 24;
@msgpack/msgpackbumped to^3.1.3. - Docs: clarified that code signing, Apple ID / Developer ID, notarization, and binary distribution are the application's responsibility — not the framework's. Removed them from the roadmap and added an explicit "Out of scope" section.
0.1.0 — 2026-07-13
First public release. Everything below is compiled, clippy-clean, and tested
(SQLite for the database layer; GUI/OS integrations are launch-smoked, with
visual or side-effecting steps called out as unverified in the docs).
Added
Core
Appbuilder — fluent assembly of a desktop app: window options, container bindings, providers, middleware, commands, and assets.- Container +
Ctx— a type-keyed service container resolvable from any command, provider, or background task (ctx.get::<T>()). - Providers — two-phase
register/bootwiring, like Laravel service providers. - Middleware pipeline — outermost-first command middleware around dispatch.
IPC bridge
elyra://localhostcustom protocol — the whole app lives under one origin: assets, commands (/__cmd/*), and the event stream (/__events).- MessagePack wire format — compact argument arrays in, named maps out (structs decode to JS objects); no JSON in the hot path.
#[command] async fn— typed commands dispatched on a multi-thread tokio runtime; the UI thread never blocks.Resultcommands surfaceErras a rejected promise (CommandError).
Events
EventBus+channel()— Rust→frontend push over a multiplexed long-poll, batched per flush; a Svelte-readable store on the frontend.
Windows, tray, updater
- Multi-window — additional windows at startup or at runtime via the
container-bound
Windowshandle. - System tray (
trayfeature) — icon + menu; clicks arrive on thetrayevent channel. - Auto-updater (
updaterfeature) — ed25519-verified update manifest, semver comparison, HTTP fetch, and signature-checked staged download. - macOS application menu — an Edit menu (so ⌘C/⌘V/⌘X reach the webview) and a custom About item.
Data (database feature)
Database— one handle over SQLite / MySQL / Postgres via sqlx'sAnydriver, with per-driver placeholder rendering.- Migrations —
rata migratewith batches, reversibledown, and status. #[derive(Model)]— Active Record with CRUD, a typed query builder (where_*,where_in,order_by,limit,get/first),bool↔INTEGER mapping,#[model(column)],#[model(timestamps)], relations (has_many/has_one/belongs_to), and N+1-avoiding eager loading (load_<name>).
Codegen & runtime
rata codegen— specta types → TypeScript definitions and a typedapi.*facade that mirrors every#[command].@elyra/runtime—invoke(),channel(), and the generatedapi.*.
Tooling
- Ratatosk (
rata) —new(scaffold with the Grove theme),dev(Vite HMR +elyra://IPC),codegen,build,bundle(macOS.app+ ad-hoc signing), andmigrate.
UI components
- About dialog — set metadata once with
App::about(AboutInfo::new(..)); the shell serves it at/__aboutand@elyra/runtimerenders a themed dialog. On macOS the standard About <App> menu item opens it; from the frontend, callopenAbout(). - Update component —
App::updater(UpdaterConfig::new(..))adds a silent startup check,/__update/check+/__update/installendpoints, progress on theelyra:updatechannel, and a themed update toast in@elyra/runtime(available → install → download → restart).Updater::apply_and_relaunchreplaces the running binary and re-execs.