Lotus.Middleware (Lotus v1.1.0)

Copy Markdown View Source

Generic middleware pipeline for query execution, schema discovery and content change hooks.

Each middleware module implements init/1 and call/2, following the standard Plug pattern:

defmodule MyApp.AuditPlug do
  def init(opts), do: opts

  def call(payload, opts) do
    {:cont, payload}   # continue to next middleware
    # or
    {:halt, "reason"}  # stop pipeline, the caller gets an error tuple
  end
end

Pipeline Events

EventTriggeredPayload keys
:before_queryFirst, before sanitization, preflight and execution:statement (%Lotus.Query.Statement{}), :source, :context, :vars
:before_executeAfter sanitization and preflight pass, before execution — or, on a cache hit, before the stored result is returned:statement (%Lotus.Query.Statement{}), :relations, :origin, :assigns, :source, :context, :vars
:after_queryAfter execution, before result returned to caller:result, :statement (%Lotus.Query.Statement{}), :relations, :origin, :assigns, :source, :context, :vars
:after_list_schemasAfter schema discovery and visibility filtering:schemas, :source, :scope, :context
:after_list_tablesAfter table discovery and visibility filtering:tables, :source, :scope, :context
:after_describe_tableAfter table schema introspection and column visibility:columns, :table_name, :schema, :source, :scope, :context
:after_list_relationsAfter relation discovery and visibility filtering:relations, :source, :scope, :context
:after_discoverAfter any discovery call, following the kind-specific :after_list_* event:kind, :result, :source, :scope, :context
:before_content_changeBefore a query, visualization, dashboard, card, filter or filter mapping is created, updated, deleted, shared or reordered:op, :resource, :record, :changeset, :context
:after_content_changeAfter that change is written:op, :resource, :record, :changes, :context

Query event ordering

:before_query fires first, before sanitization and preflight, because a plug may rewrite the statement — row-level security and tenant predicates are the point of the hook. Sanitization and preflight then apply to what will actually execute.

:before_execute fires after sanitization and preflight pass and before the statement executes. Its :statement is the rewritten one, and its :relations is what preflight proved the statement touches — read back from the cache entry when the result is served from the cache. The two hooks answer different questions: :before_query asks which statement should run, :before_execute asks whether the statement may proceed given what it provably touches.

The :relations payload

:relations is what preflight knows about the statement. :before_execute and :after_query carry the same value.

  • A list of {schema, table} tuples, where schema is nil for a source that has no schemas, is the set preflight proved the statement touches. An empty list means it touches no relation — SELECT 1.
  • {:unrestricted, reason} — the adapter cannot name the relations a statement touches (Elasticsearch, for one), and the host opted in via :allow_unrestricted_resources.
  • {:skipped, reason} — the adapter does not preflight this statement (the SQL adapters skip EXPLAIN, SHOW and PRAGMA), so nothing was analysed.

The two tuples mean "unknown". A plug that authorizes on the table list matches when is_list(relations) and halts on anything else, rather than read a tuple as an empty set of tables.

The :origin payload

:origin is :executed when the result came from the source on this call and :cached when it was served from the result cache. Both events carry it, so a plug that meters usage or cost can tell a read from an execution.

The :assigns payload

:assigns carries what a :before_execute plug decided to the :after_query plugs of the same call. :before_execute starts with assigns: %{}, each plug sees what the plugs before it left, and :after_query receives the :assigns of the payload the last :before_execute plug continued with — on a cache hit too, where the gate runs again for this call. A plug that resolves grants at the gate hands them to a plug that restricts columns, and the decision is made once.

:assigns is the only key Lotus reads back from :before_execute. A change to :statement, :relations or any other key is ignored, and a value that is not a map reaches :after_query as %{}. The assigns belong to the call that made them: the result cache never stores them, so one caller's assigns never reach another.

Derived statements

window: [count: :exact] runs a second statement, derived from the page statement, to compute meta.total_count. That run carries the caller's :context, :vars, :scope and read-only setting, and fires :before_execute — it touches the same tables as the page and is authorised the same way. It does not fire :before_query, because it is derived from the statement that hook already returned and a rewriting plug would apply twice. It does not fire :after_query, because it has no result the caller reads. A halt on the count run leaves meta.total_count as nil and the page result intact.

:vars is the map of bound query variables, by name, after defaults and caller-supplied values are merged (%{"start_date" => "2026-01-01"}). It is %{} for a raw statement run through Lotus.run_statement/3. A plug can enforce rules on the values a caller picked (date-range limits, tenant checks) without parsing the statement.

Caching

Every query event fires on a cache hit. Only the raw execution is cached, and the relations preflight found are stored with it, so :before_execute carries them whether the result came from the source or from the cache. On a miss, sanitization, preflight and :before_execute run before the entry is written, and the entry holds the result and the relations, never the assigns. Middleware is therefore safe to make context-dependent — per-user access control, audit — without keying the cache on :context.

A :before_query plug that rewrites the statement keys its own entry: the event runs before pagination and before the key is built, so the body, the bound values and the window all describe what will execute. What an :after_query plug makes of the result goes to that caller and is not written back; a halt there withholds the result from that caller, and the rows stay cached for one whose plugs let them through.

What a hit does skip is the work between sanitization and the rows: statement sanitization, preflight table authorization, and column visibility. Those read :scope, which is part of the cache key — so a visibility resolver must decide from (source, relations, column, scope) alone. Per-actor logic that cannot be expressed that way belongs in middleware.

Observing a run

Middleware decides; it does not observe. A halt in :before_query or :before_execute means :after_query never fires, so a plug there cannot see a refusal, and a plug sees only the calls its own event covers. The [:lotus, :run, :start | :stop | :exception] telemetry events bracket the whole run instead — every phase, on a cache hit and on a halt alike — and carry the caller's :context, the relations, the origin and, on failure, the phase that failed. An audit or usage consumer attaches to those. See Lotus.Telemetry.

Discovery event ordering

Discovery calls (Lotus.list_schemas/2, list_tables/2, describe_table/3, list_relations/2) fire two events each:

  1. The kind-specific event (:after_list_schemas, :after_list_tables, :after_describe_table, or :after_list_relations) — receives the kind-specific payload with a key matching the returned value.
  2. The unified :after_discover event — receives a uniform payload %{kind:, source:, result:, context:} so a single middleware module can handle every discovery kind by dispatching on :kind.

If any middleware in either phase halts, later middleware do not run and the caller receives {:error, reason}.

Content change events

Every create, update and delete of a query, visualization, dashboard, dashboard card, dashboard filter or filter mapping fires :before_content_change and then, when the change is written, :after_content_change. So do reordering dashboard cards and enabling or disabling public sharing. The mutation functions in Lotus, Lotus.Storage, Lotus.Dashboards and Lotus.Viz take opts with a :context, as Lotus.run_query/2 does.

  • :op is :create, :update, :delete, :enable_sharing or :disable_sharing.
  • :resource is :query, :visualization, :dashboard, :dashboard_card, :dashboard_filter or :filter_mapping.
  • :record is the struct as stored on :before_content_change, or nil on a create, and the struct as written on :after_content_change.
  • :changeset is the Ecto.Changeset Lotus is about to write. On a delete it has no changes.
  • :changes maps each changed field to its written value, embedded fields included. It is %{} on a delete.

:before_content_change fires whether or not the changeset is valid, so a refusal does not first tell the caller what is wrong with the input. A halt makes the mutation function return {:error, {:halted, reason}} and nothing is written. The :halted tag keeps a refusal apart from {:error, %Ecto.Changeset{}} and {:error, :not_found}, which the same functions already return. A plug may not change the changeset: Lotus writes the changeset it built.

Public sharing

Lotus.enable_public_sharing/2 fires :enable_sharing and Lotus.disable_public_sharing/2 fires :disable_sharing, both on :dashboard with :public_token in the changes. On :enable_sharing, :record holds the token as stored: nil for a first enable, the previous token for a rotation. :public_token is still a field Lotus.update_dashboard/3 accepts, and setting it there is an :update whose changes carry it, so a plug that refuses sharing checks both.

Reordering cards

Lotus.reorder_dashboard_cards/3 fires one :update of :dashboard_card for each card whose position changes, with :position in the changes. Every :before_content_change runs before any position is written, so a halt on one card leaves every position as it was. The positions are written in one transaction, and :after_content_change fires for each card after it commits.

After the write

:after_content_change fires after the change is written. An update whose changeset has no changes writes nothing and fires no :after_content_change. The write has happened, so the event cannot stop it: a plug that halts, raises, throws or exits is logged, the later plugs on the event do not run, and the caller still receives {:ok, record}. The event fires when the write returns, or for a reorder when its transaction commits. If the caller wraps the call in its own transaction, that transaction has not committed yet.

Deletes that remove other content

A delete fires one event, for the record it names. The content removed with it fires none, so the parent's :delete is the event to gate and to record.

  • Deleting a dashboard deletes its cards, its filters and their filter mappings.
  • Deleting a card or a filter deletes its filter mappings.
  • Deleting a query deletes its visualizations, and sets query_id to nil on every dashboard card that showed it.

A delete by id that finds no record returns {:error, :not_found} and fires no event.

Observing content changes

The [:lotus, :content, :change, :start | :stop | :exception] telemetry events bracket every content change, refused or not. :stop carries the :after_content_change metadata and is emitted for an update that wrote nothing, too. :exception carries the reason the caller receives: {:halted, reason} for a refusal, the changeset for a failed validation. See Lotus.Telemetry.

Configuration

config :lotus,
  middleware: %{
    before_query: [
      {MyApp.AccessControlPlug, []},
      {MyApp.QueryAuditPlug, [repo: MyApp.AuditRepo]}
    ],
    after_query: [
      {MyApp.QueryAuditPlug, [repo: MyApp.AuditRepo]}
    ],
    after_list_tables: [
      {MyApp.TableFilterPlug, []}
    ]
  }

Context

A :context key carries opaque user data (e.g. the current user) through to middleware. Lotus never inspects this value.

Summary

Types

The error reason a mutation function returns when a :before_content_change plug halts, wrapping the reason the plug gave.

Functions

Compiles all middleware by calling init/1 on each module and stores the result in :persistent_term for fast runtime access.

Runs the middleware pipeline for the given event.

Types

compiled_entry()

@type compiled_entry() :: {module(), term()}

discover_kind()

@type discover_kind() ::
  :list_schemas | :list_tables | :describe_table | :list_relations

event()

@type event() ::
  :before_query
  | :before_execute
  | :after_query
  | :after_list_schemas
  | :after_list_tables
  | :after_describe_table
  | :after_list_relations
  | :after_discover
  | :before_content_change
  | :after_content_change

halted()

@type halted() :: {:halted, term()}

The error reason a mutation function returns when a :before_content_change plug halts, wrapping the reason the plug gave.

middleware_spec()

@type middleware_spec() :: {module(), keyword()}

pipeline_result()

@type pipeline_result() :: {:cont, map()} | {:halt, term()}

Functions

compile(middleware_config)

@spec compile(map()) :: :ok

Compiles all middleware by calling init/1 on each module and stores the result in :persistent_term for fast runtime access.

An empty config clears the compiled pipeline, so reloading a config that no longer declares middleware actually turns it off.

run(event, payload)

@spec run(event(), map()) :: {:cont, map()} | {:halt, term()}

Runs the middleware pipeline for the given event.

Returns {:cont, payload} if all middleware passed, or {:halt, reason} if any middleware halted the pipeline.

When no middleware is configured for the event, returns {:cont, payload} with zero overhead beyond the map lookup.