This guide covers upgrading a host application from lotus_web 0.14.x to 1.0.
lotus_web 1.0 tracks Lotus core 1.0, which locks the public API for the 1.x line. Most of what you have to change comes from core rather than from the dashboard itself, so read the core upgrade guide first and apply the config and API renames it lists. This guide covers only what is specific to lotus_web.
If you never wrote a Lotus.Web.Resolver, never overrode a translation, and
never rendered Lotus.Web.AiAssistantComponent yourself, the whole upgrade is
sections 1 and 2 — plus section 3 if you set a Content-Security-Policy header,
which is the one change in this release that can break a working dashboard
without any code of yours being involved.
1. Elixir floor is now 1.18
Core 1.0 declares elixir: "~> 1.18", so a host app on 1.17 cannot resolve
this release. Upgrade your toolchain before bumping the dependency.
2. Bump the dependencies and migrate
def deps do
[
- {:lotus, "~> 0.16"},
- {:lotus_web, "~> 0.14"}
+ {:lotus, "~> 1.0"},
+ {:lotus_web, "~> 1.0"}
]
endmix deps.get
mix ecto.migrate
The migration is core's, not the dashboard's. It renames the lotus_queries
data_repo column to data_source and adds the query_language column. See
the core guide for the Postgres, MySQL and SQLite details.
Watch for data_repo: attribute keys while you are there — in
Lotus.create_query/1 and Lotus.update_query/2 attrs, in seeds, and in test
fixtures. In 1.0 the changeset rejects the stale key outright rather than
letting cast/3 drop it:
data_source: "was given as `data_repo`, which was renamed to `data_source` in Lotus v1.0"That is a deliberate change. Dropping the key used to save a query with no source at all, so a dashboard card built from it ran against the default source instead of the one you named. You now get a failed changeset at the call site instead.
Then apply core's config renames, which the dashboard inherits:
config :lotus,
- ecto_repo: MyApp.Repo,
+ storage_repo: MyApp.Repo,
- default_repo: "main",
+ default_source: "main",
- data_repos: %{"main" => MyApp.Repo}
+ data_sources: %{"main" => MyApp.Repo}There is no compatibility shim. Core validates its config at boot and raises
an ArgumentError naming each old key and its replacement, so you find out at
start-up rather than at the first query.
A new NIF enters your build
mdex replaces earmark as the markdown renderer (§7 covers what that
changes on screen), and mdex is a Rust NIF that arrives through
rustler_precompiled. For most hosts mix deps.get downloads a prebuilt
artifact and nothing else changes, but plan for it if any of this is true:
- You build releases in a slim container. The precompiled artifact is fetched
at
deps.gettime, so it must be in the image layer you copy forward. - You target an architecture with no prebuilt artifact, or you set
RUSTLER_PRECOMPILED_FORCE_BUILD. Then the build needs a Rust toolchain. - You vendor dependencies or audit checksums. There is a new
rustler_precompiledentry and a new fetch at build time to account for.
3. The stylesheet and JS bundle are no longer inlined
Before 1.0 the dashboard inlined its whole stylesheet and JavaScript bundle
into every page — roughly 2 MB of markup per request, re-sent on every
navigation, and vulnerable to any tool that injects markup before </head>
or </body> (Tidewave, Phoenix LiveReloader) landing in the middle of the
inlined script.
They now load from two routes under your mount path, added by
lotus_dashboard/2 itself:
<prefix>/css-<hash><prefix>/js-<hash>
Lotus.Web.Assets serves them with
cache-control: public, max-age=31536000, immutable, gzipped when the
client's accept-encoding allows it, and answers a hash this build did not
produce with a 404 and cache-control: no-store. The route helper is
lotus_asset_path/3.
Nothing to configure. The routes are declared inside your existing
scope, so they go through the same pipeline the dashboard does. They do not
touch Plug.Static, your endpoint's :only list, or your asset build.
If you set a Content-Security-Policy, check it
This is the part that can break a working dashboard. Both tags still carry a
nonce when you pass :csp_nonce_assign_key, so a nonce-based policy keeps
working unchanged. What no longer works is allowing the stylesheet with
style-src 'unsafe-inline' alone: 'unsafe-inline' governs inline style
content, and the stylesheet is now an external request. Same for the bundle
and script-src.
- "style-src 'unsafe-inline'; " <>
+ "style-src 'nonce-#{nonce}'; " <>'self' works too, if you would rather not run nonces. The
installation guide has the full
directive table.
Tabs stay current across a deploy
On the connected mount the dashboard compares the phx-track-static URLs the
client is tracking against the hashes of the running build, and issues a
full-page redirect when they differ. A tab left open through a release picks
up the new bundle instead of running old JavaScript against new server code.
Phoenix.LiveView.static_changed?/1 is not used for this — it only knows the
host application's static manifest, not Lotus's.
4. If you wrote a Lotus.Web.Resolver
Nothing you already implemented changed. Two optional callbacks were added, and without them the dashboard calls core exactly as it did before — same middleware payloads, same cache keys.
They matter if you run access control, multi-tenancy, or per-user auditing.
Before 1.0 the dashboard ran every query with no actor, so a middleware plug
or a visibility resolver saw nil for everything a person did in the browser.
defmodule MyApp.LotusResolver do
@behaviour Lotus.Web.Resolver
# Who is acting. Reaches middleware and telemetry.
@impl true
def resolve_context(%MyApp.User{id: id, roles: roles}), do: %{user_id: id, roles: roles}
def resolve_context(nil), do: nil
# What they may see. Reaches the visibility resolver, and is hashed into
# the cache key so two scopes never read each other's cached rows.
@impl true
def resolve_scope(%MyApp.User{tenant_id: tenant_id}), do: %{tenant_id: tenant_id}
def resolve_scope(nil), do: nil
endThe resolved pair rides along as :context and :scope on every core call
the UI makes: running a query from the editor or a dashboard card, streaming a
CSV export, building the sources map, describing a table for autocomplete,
listing schemas for the source picker, testing a dropdown's options query, and
every AI generate, optimize and explain call.
Both callbacks take the value resolve_user/1 returned, and both are rebuilt
on every mount, so neither has to survive a session round trip. Keep the scope
low-cardinality — a tenant id, not a user id with a timestamp — because it is
hashed into the cache key, and a scope that differs per request stops the
caches earning their keep.
One route is deliberately outside all of this: the public dashboard at
<prefix>/public/:token mounts with no resolver, so it has no user, no
context and no scope, and it is fixed at :read_only. That was already true
before 1.0, and adding the two callbacks does not change it: if you rely on a
middleware plug or a scoped visibility resolver to hide rows, a public
dashboard never gets that protection.
5. If you overrode translations
Seventeen gettext strings were rewritten to drop "SQL", because the dashboard
now speaks to non-SQL sources too — "SQL Query" became "Query",
"Enter SQL to run query" became "Enter a query to run", and the AI
assistant's hint and its explain and optimize button titles changed the same
way.
Gettext keys off the source string, so an override attached to an old msgid
silently stops applying — no error, the default English just comes back.
Re-run mix gettext.extract --merge and re-translate whatever lands in the
new msgids.
6. If you render AiAssistantComponent yourself
Core renamed every public AI key that named SQL. The component followed.
| Before | Now |
|---|---|
current_sql attr | current_statement |
@message.sql | @message.statement |
phx-value-sql on the "use this query" button | phx-value-statement |
The same rename reaches anything that builds conversation messages by hand,
or that reads Lotus.AI.generate_query/1, suggest_optimizations/1 or
explain_query/1 results. Lotus.AI.Conversation's schema_context field is
now source_context.
If you style AI optimization suggestions by type, the schema-reshaping type is
now "structure" rather than "schema". A response from an older model that
still says "schema" renders with the fallback color rather than crashing.
7. Behaviour changes worth knowing
These need no code change, but they change what the dashboard does.
Variable dropdowns populated from a query are gated per source. The "From
query" mode appears only where the source declares core's :dynamic_options
feature, which every SQL source does. Nothing changes for a SQL-only
dashboard; the gate exists so that a source whose query language returns
shaped documents rather than a flat column of values offers manual entry
instead of a mode it cannot serve.
Saved queries record their language. The editor stores the source's
query_language ("sql:postgres", "sql:mysql", …) on save. If a
source is later repointed at an engine that speaks a different language, core
rejects the stored query instead of running it against the wrong engine. The
comparison is exact, not family-level: sql:postgres and sql:clickhouse
share a family but are not interchangeable. Queries saved before this upgrade
record nothing and keep the old derive-from-adapter behavior.
The editor takes its language from the source. A source whose
query_language starts with json: gets CodeMirror JSON mode with
structure-aware completions rather than SQL mode, and a {} pretty-print
button (Cmd/Ctrl+Shift+F). SQL sources are unaffected — they keep their
dialect's highlighting and completions, and do not show the button, because
SQL has no lossless structural expansion.
SQL-only affordances are hidden rather than broken. The search_path
badge appears only where the source supports a search path, and export
parameters skip search_path for the rest. A SQL-only dashboard sees no
difference.
Rendered markdown drops embedded HTML. AI responses and dashboard card
text render through MDEx with unsafe: false, replacing the retired
earmark. Raw HTML inside markdown is dropped rather than emitted. This
closed an XSS hole; if you relied on raw HTML in card text, it will no longer
render.
The schema explorer no longer builds on the disconnected mount. The first paint stopped waiting on a schema and table listing for every configured source. Nothing to change; pages just render sooner.
Checklist
- [ ] Elixir 1.18 or newer
- [ ]
{:lotus, "~> 1.0"}and{:lotus_web, "~> 1.0"} - [ ]
mix ecto.migrate - [ ] Config renamed:
storage_repo,default_source,data_sources— coreraises at boot on the old keys - [ ] Host-app calls renamed — grep for
data_repo,run_sql,`get_table_schema` (now `run_statement/3` and `describe_table/3`) - [ ]
data_repo:attribute keys renamed increate_query/1and`update_query/2` attrs, seeds and fixtures — the old key now fails the changeset - [ ] CSP checked —
style-src/script-srcmust allow an external request(nonce or `'self'`), not only `'unsafe-inline'` - [ ] A Rust NIF (
mdexviarustler_precompiled) is in the dep tree — checkyour release or container build - [ ] Translation overrides re-merged if you have any
- [ ]
current_sqland@message.sqlrenamed if you render the AI component - [ ]
resolve_context/1andresolve_scope/1added if you run access control - [ ]
config :lotus_web, strict_actor: trueindevandtestto catch adashboard call that lost the actor
Reporting upgrade issues
If something here is wrong or missing, open an issue on lotus_web. If the problem is in a core rename rather than the dashboard, report it on lotus instead.