Upgrading lotus_web to v1.0

Copy Markdown View Source

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"}
   ]
 end
mix 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.get time, 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_precompiled entry 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
end

The 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.

BeforeNow
current_sql attrcurrent_statement
@message.sql@message.statement
phx-value-sql on the "use this query" buttonphx-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 — core
    raises 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 in create_query/1 and
    `update_query/2` attrs, seeds and fixtures  the old key now fails the
    changeset
  • [ ] CSP checked — style-src/script-src must allow an external request
    (nonce or `'self'`), not only `'unsafe-inline'`
  • [ ] A Rust NIF (mdex via rustler_precompiled) is in the dep tree — check
    your release or container build
  • [ ] Translation overrides re-merged if you have any
  • [ ] current_sql and @message.sql renamed if you render the AI component
  • [ ] resolve_context/1 and resolve_scope/1 added if you run access control
  • [ ] config :lotus_web, strict_actor: true in dev and test to catch a
    dashboard 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.