Compare commits

...

1482 Commits

Author SHA1 Message Date
Bryan Maass
2611fb2fc2 Register the exec-log module (MON-21)
Owned by Graphy. `:api` is deliberately two namespaces, so callers get
`exec-log.core` only and the schema, record, sink, and settings stay internal --
the wire format cannot be reached around.

Also adds `exec-log` to query-processor's `:uses`, since the QP now calls the
facade from `save-execution-metadata!`.

Linting the module went from 9 warnings to 0.
2026-09-25 21:06:30 -06:00
Bryan Maass
955dee0e19 Emit an exec-log record per query execution (MON-1, MON-12)
Records are emitted from `save-execution-metadata!`, after `include-sdk-info` has
run and before the batch handoff. That window is forced by where the data lives:
`include-sdk-info` attaches the IP address, user agent, and auth method on the
query thread, because the request is gone by the time the batch drains. The
`:event/card-query` publication earlier in the same file has none of those fields
and fires only for saved cards, so it cannot cover ad-hoc questions, native
queries, subscriptions, or transforms.

Emitting before `grouper/submit!` also keeps the sink off the DB-write path, so a
sink failure cannot affect the QueryExecution insert.

`record.clj` is pure: it reads no settings and does no IO, so the translation can
be tested by handing it a map. The caller passes `pii?`, having read the setting
on the query thread for the same reason `include-sdk-info` runs there. With PII
retention off, `:request` is nil even when the row carries an IP, so the setting
is authoritative rather than advisory.

`instance-id` reuses `analytics-uuid` rather than minting a setting (MON-12).

The stream is off by default: the sink setting defaults to `:noop`, and
`enabled?` short-circuits before a record is built.
2026-09-25 21:06:23 -06:00
Bryan Maass
48d089bcac Add a pluggable sink for the exec-log stream (MON-3)
`EventSink` has one method. Three implementations: file (JSONL append), memory
(the test seam), and noop, which is the default and the off switch. The call site
knows only the protocol, so a later deployment can add HTTP or Kafka without
touching the query path.

`emit-record!` validates against the schema before writing. Validating on the way
out is the point: a consumer can then treat the file as proven-shaped rather than
hopefully-shaped.

Nothing here throws. A missing path, an unwritable file, an unknown sink kind, or
a malformed record all log and continue, because this runs on the query thread
and must not be able to fail a user's query.

The drop log records error paths and types, never values. A malformed record can
carry an IP address and a user agent, and `:request` exists so those are gated on
`analytics-pii-retention-enabled`; logging the whole record would route them into
the application log, which has neither that gate nor the same retention.

The file sink opens and closes per record. A writer pool needs a lifecycle and a
rotation story that is not decided yet, and `with-open` cannot lose a record to a
half-flushed buffer at shutdown.
2026-09-25 21:06:12 -06:00
Bryan Maass
f3c4da221a Add the exec-log wire format schema (MON-2)
The execution-log stream carries one JSON record per query execution for an
external consumer to read. This is the format both ends agree on.

A record is an envelope plus a payload dispatched on `:topic`. The envelope is
identical for every topic, so a consumer can rely on it without knowing the
topic. Every map is `{:closed true}`, which makes widening the format a
deliberate schema edit rather than an incidental `assoc` at a call site.

Three groups are unions rather than flat fields, because the QueryExecution row
they come from can encode contradictions:

- `:target` - the row has six independent nullable ID columns, so a record could
  claim to be both a card and a transform. Note the `:lens` branch carries a
  string id while its siblings carry integers, matching `sdk-info-row`.
- `:outcome` - the row has `error` beside a status, so a completed execution
  could carry an error message.
- `:cache` - the row has `cache_hash` beside `cache_hit`, so a miss could carry
  a cache key.

`:context` accepts any keyword rather than an enum. It comes from
`metabase.lib.schema.info/context`, which grows on its own schedule; a closed
enum here would make a structurally valid record undecodable, and it is the
field rules predicate on most.

The version rule, documented in the namespace: bump `:v` when a change requires
more from the consumer or provides less than before. Adding an enum value, an
optional field, or a new topic is not a bump.
2026-09-25 21:06:02 -06:00
Romeo Van Snick
2602fec3cd Record how many bytes of CSS a page loads (#82541)
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:38:33 +10:00
Kevin Kelleher
b9d8328b19 Adds 'Upgrade now' banner (#82214)
* initial upgrade now banner

* remove security center integration

* fix compareVersions types

* automation, cleanup, tests, fetch version-info.json every hour

* Change CheckForNewVersions poll interval to every 6 hours

https://github.com/metabase/metabase/pull/82214#discussion_r3993430218

---------

Co-authored-by: Mike Appleby <86076+appleby@users.noreply.github.com>
2026-09-16 20:27:32 +00:00
bryan
7d94211ecd Fix two closed-schema regressions from #82447 (#82581)
* Accept localized names in CollectionInstance

The sidebar fails to load: any call passing the Trash collection through
`CollectionInstance` throws.

    Invalid input: ["an instance of the root Collection, got: (toucan2.instance/instance
    :model/Collection {... :name #metabase.util.i18n.UserLocalizedString{:format-string
    "Trash" ...} :type "trash" ...})"]

`trash-collection` sets `:name` with `deferred-tru`, yielding a
`UserLocalizedString` rather than a `String`. #82447 added `CollectionInstance`
with `[:name :string]`, which that value cannot satisfy, so the Trash
collection matches neither branch of `[:or RootCollection CollectionInstance]`.
Malli reports only the first branch's `:error/message`, which is why the error
misleadingly names the root Collection.

Widen `:name` to accept localized strings via the existing
`ms/LocalizedString`. The deferral is deliberate: `trash-collection` is
memoized across requests, so eager-rendering with `str` would freeze the name
in whichever locale filled the cache (#59662). `:slug` and `:description` are
left alone; they are not localized.

Reproduced and verified against a running instance: `shared-tenant-collection?`,
`children-location` and `descendant-ids` no longer throw on the Trash
collection, and `GET /api/collection/tree`, `/api/collection`,
`/api/collection/:trash-id` and `/api/collection/:trash-id/items` all return
200 with the name rendering as "Trash".

* Reopen the cols schema in the static-viz color picker

Rendering a table with conditional formatting throws:

    Invalid input: {:cols [{:display_name ["disallowed key, got: \"Category\""],
                            :base_type ["disallowed key, got: :type/Text\""]} ...]}
    fn-name: cell-background-colors

`QueryResults` describes the value handed to the shared color-picking JS. Its
docstring says it is "a pretty loose schema, more as a safety net" that "just
needs column names from `:cols`" — callers pass whole column metadata maps and
only `:name` is ever read. #82447 closed the inner `:cols` map, so every caller
passing `:display_name` or `:base_type` now fails validation.

Mark that map `::mr/deliberately-open` to restore the documented intent. The
outer map stays closed; the extra keys only ever appear on columns.
2026-09-16 20:23:32 +00:00
Chris Truter
7c771f3c33 Add GPT-6 Astra support (#82499)
* Add GPT-6 Astra to the OpenAI, Azure and Bedrock model pickers

The OpenAI allowlist stopped at the 5.6 family, and the reasoning/temperature
check keyed off a gpt-5 prefix, so a forced request would have sent
temperature, which Astra rejects. Widen the check to GPT-5 and GPT-6 and add
the model to the three adapters that sell it directly.

OpenRouter is left out: its allowlist needs a live reasoning probe per model.

* Correct Bedrock context window comment
2026-09-16 20:17:17 +00:00
Romeo Van Snick
3c3979838a Move the SDK storybook instance URL out of the storybook config (#82544)
Claude-Session: https://claude.ai/code/session_01FeHJzstcAE34DTu4G6btjx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:06:06 +10:00
Eric Normand
49a7152e97 Give flat-table-content typed argument schemas so body.clj loads (#82591)
* Give flat-table-content typed argument schemas so body.clj loads

#82447 made mu/defn throw at macroexpansion when an argument's schema does
not declare its shape. #82277 landed flat-table-content with an unschema'd
card argument and an unschema'd open map, so requiring
metabase.channel.render.body now fails to load.

Use the same argument schemas as its sibling prep-for-html-rendering, which
receives the same two values from `render :table`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8

* Narrow simple-pivot-content's color-data cols to :name

The second collision between #82447 and #82277: #82447 closed the cols
entries of js.color's QueryResults to exactly {:name :string}, and #82277's
simple-pivot-content hands cell-background-colors full cols, so every
simple-pivot-* test errors with :metabase.util.malli.fn/invalid-input.

Narrow the cols the way the flat-table path already does. The colors are
unaffected: the JS reads cols only to build a name->index map
(makeCellBackgroundGetter -> getColumnIndexesByName).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:32:54 +02:00
Timofey Kachalov
a367aac5d8 Require defineQuery and defineAction exports in data-app hooks (#82489) 2026-09-16 20:19:27 +04:00
Maksym Yakubych
f02360567a Make Metabot metric mentions clickable (#82348) 2026-09-16 16:12:47 +00:00
Eric Normand
6318ae1d86 Build MCP v2 agent-facing text with msg so interpolated values are quoted and escaped (#82437)
* Add a kondo lint for unquoted interpolation into agent-facing prose

Teaching errors, paging hints, and steering lines read to an LLM as the
server speaking. A warehouse table name, driver error, or card name
interpolated into that prose unquoted can carry newlines and text that
pose as server-authored instructions.

`:metabase/unquoted-prose-interpolation` flags `format` %s arguments,
`tru`-family {n} arguments, and non-literal arguments of a `str` call
over a prose literal, unless they are literals, `pr-str` calls, or a
`str/join` over `(map pr-str ...)`. It is enabled only for
`metabase.mcp.v2.*` and `metabase.agent-lib.*` source namespaces.

The rule currently reports 243 findings; a follow-up tunes out the
noise and fixes the real sites.

* Scope the prose-interpolation lint to MCP v2 and the MCP transport

* Add the MCP v2 message builder and lint its lines

`metabase.mcp.v2.message` builds agent-facing prose: `msg` takes a
vector of line format strings plus arguments, and `render` joins the
lines with newlines and interpolates the arguments, cleaning each one
unless it is `raw` or a nested message. `clean` quotes with `pr-str`
and escapes invisible, line-breaking, and quote-like characters as
\uXXXX. `render` never throws: a malformed message, a format failure,
or anything that isn't a message renders with every part cleaned.

`:metabase/agent-message-lines` checks `msg` calls: the lines must be a
literal vector of string literals with no line breaks, `%n`, or
control characters, and the arguments must match the specifiers.

* Render MCP v2 messages at the tool-content and JSON-RPC exits

Teaching errors thrown with a message render it into the exception
message and carry the message in ex-data, so the caller-facing error
paths hand it on to be rendered. error-content, success-content, and
jsonrpc-error render messages; plain strings pass through unchanged for
now. message? is now public.

* Lint that MCP v2 exits receive msg-built text

Add :metabase/agent-message-exit, enabled for agent-prose-namespaces. It flags the message argument of throw-teaching-error, error-content, jsonrpc-error, and success-content when it is syntactically text (a string literal, a string-building call, or a threading, branching, or body form yielding one), and ex-info calls whose data literal carries a 4xx :status-code or an error-code key.

Point the unquoted-prose-interpolation messages at msg instead of pr-str.

* Scope the agent-prose lints in their hooks instead of with :off switches

The prose-interpolation and agent-message-exit linters are :warning globally; their hooks run only in non-test metabase.mcp.v2.* and metabase.mcp.transport namespaces. agent-message-lines runs in any non-test file. This keeps config.edn free of new :off switches for the kondo ratchet.

* Build shared MCP v2 prose with msg

Convert the shared response helpers, the transport's JSON-RPC errors, the
registry's pre-dispatch errors, and the API's method and resource errors to
msg. Registry errors now carry a message, humanized schema detail cleans its
paths and expectations, and the query pipeline's rewrapped schema failure
keeps the original message as a cleaned argument. Adds message-ex-info and
list-message to common.

* Build shared MCP v2 write, resolve, and skills prose with msg

Convert the _write dispatch, id resolution, recipient, projection, skills,
and recovery-hint text. Caller-supplied values (ids, clear names, methods,
recipients) are cleaned; server-declared names stay bare. The skills
catalog, pack, and reference texts are now messages, and the recovery
hints render msg-built English text for the shared pipeline.

* Build MCP v2 bookmark and UI credential prose with msg

* Build MCP v2 alert and subscription prose with msg

* Build MCP v2 transform prose with msg

* Build MCP v2 collection, duplicate, content, and document prose with msg

Card types, include sections, and document snippets are now cleaned in
teaching errors; the tools' own argument keys and type names stay bare.
The default copy name stays a plain content name, and get_content passes
its payload to success-content for encoding.

* Build MCP v2 metric, segment, measure, and question prose with msg

Normalizer and pipeline exception text, card types, template tag names,
column names, and schema summaries are cleaned in teaching errors. The
definition-shape and metric shape-rule sentences are messages nested into
the errors that end with them, so resolve-external-query receives a
message hint. The native-SQL scope refusal goes through
throw-teaching-error.

* Build MCP v2 parameter-values and dashboard-op prose with msg

Stored parameter ids and names, template-tag names and types, and text-card
placeholders are cleaned in teaching errors, listed with list-message. The
get_parameter_values steering lines are messages nested after the JSON
payload, including the shared truncation line. op-error! takes a message
detail; op and argument names the server declares stay bare.

* Build search, learn, and visualize MCP v2 prose with msg

* Build query and dashboard MCP v2 prose with msg

* Build browse MCP v2 prose with msg

* Name the op in dashboard op errors instead of in their detail

op-error! now takes the op name separately and renders `op <idx> (<op-name>): <detail>`, nesting the
detail message. Callers that still passed strings (the unknown-card check, add_link, and the unknown-op
fallback) rendered doubly quoted; the unknown-op fallback cleans the caller's unmatched op name itself.

* Clean plain strings whole at every MCP v2 exit

error-content, success-content, and jsonrpc-error now render everything
through message/render, so a plain string (a string teaching error, or an
upstream exception message surfaced by ->mcp-error-content or
caller-safe-error-message) reaches the client quoted and escaped instead
of passing through. Only msg values render as prose; "Internal error" is
now a msg so it stays unquoted.

* Scope MCP v2 prose linters by config and retire unquoted-prose-interpolation

Remove the format/str/tru interpolation checks now that msg cleans values at
runtime, restoring the i18n hooks to strict-apostrophes. msg lines may be split
with str over literals, and %s can't take width, precision, or flags, nor %S.
The linters are enabled for MCP transport and v2 source namespaces through an
ns-group instead of in-hook namespace checks.

* Harden MCP v2 message rendering, truncation, and rewrapped errors

clean escapes only double-quote look-alikes, keeping single quotes such as
U+2019 so names survive being copied back. render treats %S and %s with
flags, width, or precision as malformed, and renders a fixed sentence when
an argument can't be printed. ellipsize shortens string arguments inside
their quotes, falling back to message/truncate, which cuts a rendering
without leaving a quoted value open. An exception rewrapped with new text
surfaces that text instead of its stale ex-data message.

* Send MCP v2 recovery hints, throttle, and extension errors as prose

Recovery hints are now messages nested after the quoted pipeline text,
added by the v2 query entry point instead of appended by the shared
pipeline. The throttle refusal and missing-extensions error are
server-written messages rather than quoted strings, and the default
duplicate name is localized again with tru.

* Describe recovery-hint contract now that MCP v2 attaches its own hints

* Surface rewrapped exception text in MCP v2 query and definition errors

Use common/exception-message instead of queries' private copy, which
preferred the stale ex-data message over a rewrapped exception's new text.

* Restore plain MCP code restructured only for the retired prose lint

Inline the SSE framing and native summary prefix, return the WWW-Authenticate
builder and schema descriptions to plain str/format, and restore master's text
for developer-only load-time and 500 errors. Agent-facing output is unchanged.

* Tighten MCP v2 message docstrings to their own contracts

* Give MCP v2 message fns malli schemas

* Wrap long MCP v2 msg lines and build the learn catalog as one message

* GHY-4544: Widen agent-message lint to all MCP source and message-taking helpers

Rename hooks.metabase.prose-interpolation to hooks.metabase.agent-message.
Check msg lines in every non-test metabase.mcp namespace; keep the exit
check on transport and v2, whose 4xx errors are MCP exits. Flag malformed
format specifiers, treat cond/condp/case results as text, and check the
messages passed to op-error!, check-execute-sql-enabled!, ellipsize, and
list-content's :empty-hint. Split lint-msg into helpers.

* Never lengthen a quoted value when truncating a message

A value one character over its budget kept its full text and still gained an ellipsis, rendering longer than the uncut message. Cut values and whole values are now measured the same way, and the cut search never considers the whole value.

* Harden message cleaning and share render's formatting with truncation

clean binds the print vars that affect quoting, so a caller's *print-readably* false can't strip the quotes off a value, and is now private. More double-quote look-alikes are escaped. render and truncation share one formatting helper, and truncation helpers are named for what they return.

* Render schema failures as readable detail and add schemas to common message helpers

An invalid-input schema failure now names its problems with humanize-detail instead of printing the humanized data as one quoted value. message-ex-info, exception-message, list-message, and ellipsize are mu/defns, the message helpers move ahead of the teaching errors that use them, and pure helpers are named for what they return.

* Omit a missing exception message from MCP v2 errors and keep the original as cause

A nil exception message rendered as null (or an empty quoted string) in the
query pipeline rewraps and the definition, metric, transform, and inline
question normalization errors. Those messages now leave that text out.
with-schema-detail and with-recovery-hint rethrow with the original exception
as the cause, so logged traces show the pipeline's throw site.

* Unquote get_content item errors and name alert schedule fields readably

A get_content item's error is a JSON string value, so a plain upstream
message goes in as it is instead of quoted and escaped twice. alert_write's
uncompilable-schedule error lists the schedule's fields with their values
cleaned, instead of a double-printed map.

* Tidy MCP v2 message construction and naming

Send the throttle library's refusal as its own text, nest raw values
directly, inline duplicate's copy-name, rename query-failed and browse's
message locals, replace search's empty filler messages with a list join,
trim two Metabot docstrings to their contract, and mark two pure
dashboard_ops tests parallel.

* Judge threading forms by their result and check :empty-hint through browse's paged-list-content

The exit check read the threaded value of -> and ->> rather than what the form
returns. ->, ->>, some->, and some->> now take text-ness from their last step,
cond-> and cond->> from the initial value or any step, as-> from its last form,
and doto from its target; ex-message counts as building a string. The
:empty-hint check is also registered for browse's paged-list-content, which
passes its options map to list-content.

* Keep a truncated MCP v2 message's pieces so truncating it again closes its quotes

A truncation is now a message of the pieces it kept: server text as raw text and
a cleaned value as itself or its cut excerpt, so a later truncation cuts each
piece as the kind it is. Also registers the message and raw schemas, shares one
surrogate-safe string-prefix, merges the argument dispatch, and renders keywords
as their names.

* Wrap long lines in the MCP v2 common tests

* Pass server text to MCP v2 helpers as messages and name missing identifiers

Helpers that interpolated a string parameter through message/raw on the strength of a
comment about their call sites now take a message: alert and subscription scope and
schedule checks, parameter lookup, segment/measure name checks, duplicate's trash check,
alert id checks, and dashboard op errors. Rendered text is unchanged, except that
dashboard_write's unknown-card refusal now quotes the op name as the caller sent it.

A missing tool name, method, op, or dashcard/tab/parameter id now gets its own sentence
instead of rendering as null.

* Keep surrogate pairs whole when cutting document_write error snippets

* Wrap MCP v2 test lines longer than 120 characters

* Cut document_write error snippets with the shared surrogate-safe string-prefix

* Pass single-line server text to MCP v2 messages as raw

A message with one line and no arguments that is only ever interpolated into another message renders the same as raw text, so those call sites now pass (message/raw "..."). Messages that reach an exit, render on their own, or are returned as a top-level message keep msg. Helper docstrings now describe those parameters as server text.

* Inline one-use MCP v2 message helpers

require-hour! in alert_write and subscription_write and catalog-line in skills existed only to keep message-wrapped lines short. With raw text at the call sites they read as direct calls again.

* Allow a plain subject for the MCP v2 execute_sql kill-switch check

The helper interpolates its subject into its own msg, so a string literal is cleaned like any value.

* Minimize raw in MCP v2 prose

Identifiers interpolated into agent-facing messages are now cleaned rather than
passed as raw; raw moves to where server text is defined. Leftover call-site
comments are removed, lists of names render via list-message/lines-message, and
test expectations are updated to match.

* Quote field names in MCP v2 errors and clean remaining identifiers

Field, argument, and tool names in fixed agent-facing msg lines use double quotes
instead of backticks, matching names interpolated through cleaning. Code snippets
keep their backticks. common.clj cleans the not-found model name, the schema-failure
function name, select-fields type names and nearest catalog paths, and the
truncation-line param instead of passing them raw. alert_write's ignored-field error
says "An" before an hourly schedule, as subscription_write does. Test expectations
updated to match.

* Close MCP v2 message arg schemas for mu/defn's shape check and name not-found types as server text

* Allow only %s, %d, and %% in MCP v2 msg lines and govern enterprise MCP namespaces

Match the runtime's allowlist: a `msg` line may use `%s` with no flags but `<`, `%d` with flags and
width but no precision, and a bare `%%`; any other conversion would alter or hide a cleaned value.
Flag private-use characters too, since the truncation markers delimit arguments with them, and cap
the argument index at nine digits so an absurd one reports as malformed instead of overflowing.

The two MCP ns-groups now also match `metabase-enterprise.mcp.*`, which they left ungoverned.

* Bound MCP v2 message printing to 100 elements and 10 levels

An unbounded sequence argument made render hang and a multi-million-element vector rendered
megabytes of text, since printed pinned *print-length* and *print-level* to nil.

* Take only strings as MCP v2 raw message text

A non-string raw was interpolated into the formatted template instead of being kept as a text
piece, which could collide with the private-use truncation markers. Message lines holding
private-use characters are now malformed for the same reason.

* Allowlist MCP v2 message format conversions and widen quote look-alikes

Only %%, a plain %s, and a %d without precision format; every other conversion — %b, %h, %c,
%x, %f, %tY and friends, which reinterpret an already-cleaned argument — makes the message
malformed, so it renders fully cleaned. The double-quote look-alike set gains the ornaments,
ditto mark, and prime family found by sweeping the Unicode blocks it draws from.

* Clean get_content per-item error text instead of trusting JSON quoting

JSON escapes control characters and double quotes but leaves category-Cf
characters (a bidi override, a zero-width space) intact, so a plain-string
caller-facing error reached the model raw. Render it like every other agent
message, which quotes and \uXXXX-escapes the whole string.

* Wrap MCP v2 message lines the branch pushed past 120 columns

Text is unchanged: each line splits into a `str` of the same pieces, or moves
a word to the continuation line.

* Flag msg lines whose argument reference String/format can't resolve

The kondo hook counted a leading %< and an explicit 0$ as consuming no
argument, so a msg using either linted clean while the runtime's format
threw and fell back to rendering every part quoted.
2026-09-16 15:53:05 +00:00
Bartosz Rybacki
f3d1360240 GDGT-2751 Apply the Table viz's pivot toggle in subscriptions (#82277)
* GDGT-2751 Apply the Table viz's pivot toggle in subscriptions

A Table card with the "Pivot table" toggle on arrives flat in a
dashboard subscription or alert. The toggle is a browser-only
transform; the three settings reach the static renderer, which
never read them.

Port the browser's pivot into `render :table`, including its sort
rule, and draw the grid with the existing pivot renderer. The grid
is built from every row and is not cut at the attachment row limit,
like a Pivot Table card. The server-side dashboard PDF gives such a
card the pivot treatment too, so it no longer prints the source row
count under the grid. CSV and XLSX attachments stay flat; they are
a separate code path.

3 functions needed to be moved. A pure move, no changes: 
`setting-value`, `pivot-cell->str` and `pivot->hiccup` go from 
the pivot section to a section of their own under the 
`render` multimethod.
2026-09-16 17:37:44 +02:00
Sloan Sparger
efe0882aca [Metabot] Remove transform_codegen profile (#82208) 2026-09-16 09:17:13 -05:00
Sébastien
f1a48e2b5c SDK entity navigation drops the click behavior filter when the target is a GUI (MBQL) question (#82409)
* Fix SDK entity navigation dropping click behavior filters on GUI questions

* Give ad-hoc question targets their own click behavior type

* Cover the GUI click behavior target with an e2e test

* Fix the internal-navigation dashcard setup clobbering itself
2026-09-16 14:51:54 +02:00
Chris Vandoodewaard
9b59d17d10 notify release managers if auto release is skipped (#82528) 2026-09-16 08:03:13 -04:00
Will Fry
b084ade539 GDGT-3224: migrate TabRow to CSS modules to fix gradient theme issue (#82482)
* GDGT: migrate TabRow to CSS modules to fix gradient theme issue

* Update Loki Snapshots

* increase scroll gradient size

* Update Loki Snapshots

* Add dark theme story for narrow dashboard with many tabs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add placeholder Loki reference for dark theme narrow tabs story

Copy of the light theme snapshot so the loki-update workflow detects a
diff and replaces it with the CI-generated image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update Loki Snapshots

* move left/right styles into existing classes

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:01:26 +00:00
Nemanja Glumac
2980fd7f29 Stub floating-ui positioning in unit tests (#82493) 2026-09-16 12:53:55 +02:00
Will Fry
6faa93e40c GDGT-3230 fix lack of highlight in CardEmbedNode title input (#82520) 2026-09-16 11:49:58 +01:00
Romeo Van Snick
d3db1a1c53 Read three single-entity lookups from their own requests (#82550)
Read the segment's table, the list's table and the editor's database from their own requests
2026-09-16 19:39:20 +10:00
Timofey Kachalov
82d075c73c Expose a DateRangePopover in the data-app SDK namespace (#82291) 2026-09-16 11:40:43 +04:00
Romeo Van Snick
9f00ac4fc0 Dissolve the hoc module into its four consumers (#82547)
Claude-Session: https://claude.ai/code/session_01FeHJzstcAE34DTu4G6btjx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:18:19 +10:00
Romeo Van Snick
4b5f24e0a9 Read the reference segment pages from their own requests (#82475)
* Read the reference segment pages from their own requests

* Remove the segment selectors from the reference store
2026-09-16 13:59:59 +10:00
Fraser
46541523bc Tier documents and require explicit shared-tier coverage (#82542)
* Tier documents and require explicit shared-tier coverage

* Check shared tier coverage directly in the unit spec
2026-09-16 15:44:38 +12:00
Fraser
1b0ff2917f Preserve the visual-test required check for Loki (#82539) 2026-09-16 15:44:27 +12:00
bryan
4a3d32ae0a Bind inline values in app-DB queries with [:auto/param ...] (#82373)
* Require app-DB value slots to hold bound parameters

HoneySQL resolves a map in a value slot toward SQL structure rather than
data, so a value can reach the database as syntax instead of as a bound
parameter. `[:param ...]` is the only form that always binds.

Adds `metabase.app-db.value-guard`:

  - `auto-param` rewrites inline `[:param* v]` markers into HoneySQL's
    `[:param :kN]` plus the params map, so callers do not keep a separate
    map in sync.
  - `long*`/`longs` coerce ids, which covers the common lookup without
    param noise and rejects anything that is not a number.
  - `assert-values-wrapped!` walks the compiled query at the Toucan
    compile step and throws when a value slot holds something that could
    become SQL.

The walk is clause-aware: it descends only into `:where`/`:having`/`:set`/
`:values`/`:join-by` and knows which argument of each operator is a value,
so column and operator positions are left alone. An operator that is not
classified fails closed.

This is an allowlist and lives apart from `honeysql-guard`, which is a
blocklist of known shapes and is backported to older versions.

Two forms needed handling beyond plain scalars and params: h2x wrappers,
which are checked by their payload rather than trusted by their wrapper,
and HoneySQL's `:%fn.col` call shorthand, which is a closed enumerated set
rather than a prefix test so that a constructed keyword cannot pass.

`strict-modules` is empty for now. A module joins it once its call sites
carry params or coercions, at which point plain scalars are rejected there
too.

* Accept java.time values in value slots

Toucan's before-insert hooks add created_at/updated_at as ZonedDateTime,
which clojure.core/inst? does not cover, so every insert was rejected.
All java.time.temporal.Temporal values bind as parameters.

* Check nested values, report the offending leaf, defer installation

Three changes, all from measuring the check against a real module.

A dev-authored marker on a subquery asserts that its SQL is dev-authored.
It says nothing about the values inside it, which are just as reachable
from a request, so the check now descends into a marked subquery instead
of passing it wholesale. `honeysql-guard` also descends here, but with a
blocklist, so a nested unwrapped value passes it today.

When a nested value was rejected the error named the enclosing subquery
rather than the value, which pointed debugging at the wrong place. The
nested check now throws on the leaf.

The check is no longer installed at the Toucan compile step. Against the
`collections` module it errored 91 of 148 tests, and none of those was an
unparameterized user value -- they were correlated subqueries, a UNION
used as an operand, and SQL function calls in `:set`, all of which are
real SQL that happens to sit in a value-bearing clause. Installing it
globally is therefore not the right first step; per-namespace opt-in is,
and that opt-in has to key off the calling namespace rather than the
model, since every Toucan model keyword is `:model/Something` and carries
no module information.

* Adopt the value check in the content-translation queries

Gives the check one real caller instead of leaving it unused. `locale`
reaches `translations-for-locale` from a request query parameter, so it is
coerced where it enters the query and the query is checked before it runs.

Adoption is an ordinary function call at the site it protects -- no compile
hook, no ambient state -- so the next namespace adopts independently and
nothing changes for namespaces that have not.

Adds `str*` alongside the id coercions. It asserts a value is already a
string, which rejects the maps and keyword vectors HoneySQL would compile as
SQL while leaving a legitimate string untouched.

`checked` deliberately does not demand bound parameters. A coercion returns
a plain scalar, indistinguishable at runtime from an unwrapped one, so that
stronger property cannot be asserted from the compiled form.

Also records a blocker found while writing this: `[:param ...]` needs its
values in a separate `:params` map, and `honeysql-guard` rejects any unmarked
nested map, so that map never reaches HoneySQL. `[:param]` is therefore
unusable through Toucan until `honeysql-guard` learns about `:params`, which
is a change to a backported namespace and is left alone here.

* Scope the value check to namespaces that have adopted it

Runs the check inside Toucan's compile step again, but only for queries
issued from a namespace listed in `enforcing-namespace-prefixes`. A query
from anywhere else compiles exactly as before, so a namespace adopts on its
own schedule and nothing has to be switched on everywhere at once.

The alternative was a `checked` call at each query site, which only works as
long as everyone remembers to write it. Putting it in the pipeline means an
adopting namespace cannot forget.

Scoping by namespace rather than by model because a model is shared: opting
in `:model/Card` would opt in collections, search, dashboards and
serialization together, which is the big-bang this is trying to avoid. A
`db.clj` is the unit a team actually cleans up, and a prefix entry covers a
whole module once it is ready.

`enforcing-caller?` reads the call stack rather than an ambient binding. The
check runs several frames below the caller inside Toucan, so a dynamic var
would have to be threaded through Toucan internals and would not survive a
thread hand-off; the stack already carries the answer. Measured at ~4us per
query against a DB round trip of hundreds, and only paid at the compile step.

`enforcing-namespace-prefixes` is public so a clj-kondo hook can read the
same set and check adopted namespaces at author time.

Tests prove both directions: a query from the adopting namespace is rejected,
and the same query from a non-adopting namespace is not. A bare keyword in a
value slot is the discriminator, since `honeysql-guard` permits it and this
check does not. `collections` stays at the baseline 1958 assertions, 0 errors.

* Bind values inline with [:auto/param ...]

Makes the design doc's ergonomic half real. A caller writes the value where
it belongs and `bound` lifts it into the params map HoneySQL wants, so the
two never have to be kept in sync:

    (bound [query {:where [:= :locale [:auto/param locale]]}]
      (t2/select :model/ContentTranslation query))

Two things had to be fixed for this to reach the database.

`honeysql-guard` rejected the `:params` map as an unmarked nested map. It
now treats `:params` like `:set` and `:values` -- keys that hold values
rather than nested queries -- so its contents are still checked. Nothing in
`:params` can become SQL regardless of shape, since HoneySQL binds it as a
parameter, so checking it catches a value JDBC would later refuse rather
than an injection.

HoneySQL also takes params as a format option rather than a query clause, so
`bound` passes them through `toucan2.honeysql2/*options*`.

The marker is `:auto/param` rather than a bare keyword so it cannot collide
with a HoneySQL operator, and an unresolved one now throws: HoneySQL would
otherwise compile `[:auto/param v]` into a `PARAM(?)` function call, so
forgetting `bound` would produce wrong SQL instead of an error.

* Lift [:auto/param ...] in the pipeline instead of at the call site

A caller now writes the marker inline and nothing else:

    (t2/select :model/ContentTranslation
               {:where    [:= :locale [:auto/param locale]]
                :order-by [:msgid]})

The compile step lifts every marker into the params map and hands it to
HoneySQL, so the query is an ordinary Toucan query and the wrapper macro is
gone. One less thing to remember, and a query that forgets the wrapper is no
longer possible to write.

The method is `:around` rather than `:before` because it has to replace the
query it passes on, not only inspect it.

An unresolved marker still throws. It now means the query reached the
database by some route other than the compile step, since anything through
the pipeline gets lifted.

* Pin what an unlifted marker does, and correct why a hostile value is refused

HoneySQL does not recognise `:auto/param`, so a marker handed straight to
`sql/format` compiles as a call to a function named PARAM over its argument.
The database then refuses the statement -- `Function "PARAM" not found` --
so it is broken rather than dangerous, but it is worth pinning, since the
marker only does its job on the path that lifts it.

Also corrects a test comment that credited JDBC with refusing a hostile
non-scalar. Through Toucan it never reaches the driver: `honeysql-guard`
rejects the unmarked map at the compile step first.

* Check every clause that can hold a value, not a list of five

The walk inspected `:where`, `:having`, `:set`, `:values` and `:join-by`.
HoneySQL has about 92 clauses, so that missed real ones -- notably join ON
conditions, which are the most used clause family in the codebase (~650
sites across `:join`, `:left-join` and friends), plus `:do-update-set` and
`:on-duplicate-key-update`.

Worse than the omissions was the direction. Unknown *operators* failed
closed, but an unlisted *clause* was skipped silently, so a clause nobody
had classified went unchecked. The walk now lists the clauses that hold only
structure -- `:select`, `:from`, `:order-by`, the DDL -- and checks
everything else, so a clause added to HoneySQL later is inspected rather
than ignored.

A join clause is a flat sequence alternating target and ON condition, and
both are keyword-headed vectors. The operator table tells them apart: a
condition starts with a known operator, anything else is a table.

Also drops `long*`, `longs` and `str*`. They were `clojure.core/long` and
`str` with docstrings -- an alias layer with no added behaviour, and nothing
used them once the marker existed. `[:auto/param]` is what the walk can
actually see; a coercion returns a plain scalar it cannot distinguish from
an uncoerced one. GHY-4475 wants amending to match.

* Load the check from app-db core, and move test scaffolding out of src

Removing the `keep-me` no-op exposed a real gap: nothing loaded this
namespace, so neither the marker lift nor the check ran in a running system.
It was masked while the adopting namespace still required it. `app-db.core`
now requires it the way it requires `honeysql-guard`, and `keep-me` is a
reference inside `comment` rather than a defn, matching the convention.

The test-only query that proved the check is scoped to its caller no longer
lives in `src`. The test namespace adds itself to the enforcing set instead,
which works now that the munged class prefixes are derived on each call
rather than captured once at load; two copies of that set could drift.

Adds a test that an unmarked string is compared as a literal. A string was
never the danger -- HoneySQL binds it -- and saying so keeps the marker's
purpose clear: it is for values that are not strings, which HoneySQL would
otherwise compile as structure.

Records that the marker belongs in a query map rather than a kv-arg. Toucan
builds kv-args into the where clause after the lift runs, so a marker passed
that way ends up inside a PARAM call instead of a comparison.

* Reduce to the value binding, and drop the walk

Review found the walk failing open in every position it did not explicitly
classify -- a map reaching `check-clause!` returned nil rather than throwing,
which left `{:where [:and [:= :a 1] {:raw "1=1 OR true"}]}` compiling to
`WHERE (a = ?) AND (1=1 OR true)`. The left-hand side of every comparison,
arguments past the declared indexes, `:join-by` conditions, and
`:limit`/`:offset`/`:fetch` were all unchecked for the same reason. A check
that advertises a guarantee it does not provide is worse than no check, so
the walk is gone rather than patched. It can come back built fail-closed.

What remains is the part that worked: a `[:auto/param v]` marker is lifted
at the compile step into HoneySQL's `[:param :kN]` plus its params map, so a
value written inline binds as `?`.

Four bugs in that half are fixed. `postwalk` descended into a marker's own
payload, so a nested marker bound a `[:param]` vector instead of the value;
it now uses `prewalk` and takes the payload whole. Toucan re-enters `compile`
with the `[sql & args]` vector it produced, so the method ran twice per
query; it now only walks a map. Param keys were sequential and guessable,
letting a `[:param :p1]` from request data bind another slot's value; keys
are now random. The options binding replaced `:params` wholesale, dropping an
enclosing compile's params; it merges.

`honeysql_guard.clj` is reverted. `:params` is never a key in a query map --
it reaches HoneySQL as a format option -- so that change was dead code in a
file backported to v58, v61 and v63, and the comment claiming `[:param]`
needed it was wrong.

* Reject a malformed marker, and do not treat a map entry as one

A marker-headed vector of the wrong arity now throws. HoneySQL does not
recognise the leftover form and compiles it into a call to a function named
PARAM, so `[:auto/param v extra]` previously failed at the database with an
error pointing nowhere near the typo.

A `MapEntry` is a two-element vector whose first element is its key, so the
map `{:auto/param v}` would otherwise look like a marker and the map holding
it would be rewritten away. That map means "a column named :auto/param", so
reaching it takes misuse rather than ordinary use -- but excluding the type
costs one check, so the walk does it rather than leaving the shape to chance.

Testing `vector?` instead of `sequential?` does not help here, since
`MapEntry` implements `IPersistentVector`.

This also drops support for a marker written as a seq. Nothing writes one,
and the looser test was widened for surface area rather than a caller.

Adds tests for the write path and for a query with no markers, neither of
which the unit tests reached: they exercise `auto-param` directly, so the
pipeline method's own branches were untested.

* Accept one marker shape, and never let one reach SQL

`[:auto/param value]` is the one supported form. Anything else headed by the
marker -- a seq, the wrong arity -- now throws instead of being left to
compile, because HoneySQL does not recognise the keyword and turns whatever
is left into a call to a function named PARAM that fails at the database
with nothing pointing back to the query.

Two assertions hold the line. Nothing marker-shaped may survive the rewrite,
and every value lifted out has to come back as a bound argument: a marker in
a slot HoneySQL formats as an identifier is rewritten but never consumed, so
`{:from [[:auto/param :t]]}` silently produced `FROM param AS p3lg...`,
dropping the value and putting the generated key in the SQL text. That now
throws.

A marked nil is left to HoneySQL rather than bound. It turns a literal nil in
a comparison into `IS NULL`, while a bound parameter gets `= ?`, which no row
satisfies -- so wrapping a nullable value silently returned nothing.

A `MapEntry` is still excluded from the marker test, so a column named
`:auto/param` is left alone rather than rewritten away.

* Look for the marked value among the arguments, not for a count

`assert-every-value-bound!` compared how many values were lifted to how many
arguments came back, which any arguments satisfy -- not specifically the ones
that were lifted. A query carrying unmarked literals has arguments to spare,
so a dropped marker hid behind them:

    {:from [[:auto/param :t]] :where [:and [:= :a 1] [:= :b 2]]}
    ;; => FROM param AS p16vl... WHERE (a = ?) AND (b = ?)   -- two args, one
    ;;    param, count satisfied, value silently gone

It now looks for each marked value among the arguments. A sequential value is
spread across one argument per element, so its elements are looked for rather
than the collection; anything else, including the map this exists to
neutralize, binds whole.

Tests cover the shapes where the number of arguments and the number of marked
values disagree: a collection, the same value marked twice, and marked values
sitting alongside unmarked ones.

* Drop the bound-value assertion, and say what is not checked

`assert-every-value-bound!` rejected ordinary queries. It inferred from a
value's Clojure type whether HoneySQL would spread it across several
arguments, but that is a decision HoneySQL makes per operator: `:in` spreads
a collection, `=` binds one whole. So a vector compared with `=`, and a set
in an `:in`, were each checked the wrong way and threw. A collection holding
`nil` or `false` threw as well, because the check used a set as a predicate
and a set returns nil for those members even when it contains them.

It was also unsound in the direction it existed for. Comparing against the
argument list loses which argument came from which value, so a dropped
marker passed whenever an unrelated literal elsewhere happened to equal it:
`{:from [[:auto/param "de"]] :where [:= :msgid "de"]}` was accepted, with the
value gone and the generated key left in the SQL as an identifier.

No post-hoc check can be sound here -- HoneySQL gives no signal for a param
it did not consume -- so the namespace docstring now states the limitation
instead of a check pretending to cover it. A marker still may not survive the
rewrite, and a malformed one still throws; both of those are about the marker
itself rather than about what HoneySQL did with it.

* Lint a converted namespace's values for a missing marker

Marking a value does nothing to keep it marked: a later edit adding an
unmarked value slot to a converted namespace passes silently. This adds the
author-time check that holds the line, extending the hook that already
enforces `db.clj` confinement rather than adding a second one -- the two are
halves of the same rule, and that hook already knows which namespaces are
`db.clj`.

It reports a symbol reaching a value slot without a marker, following the
clause shapes a query map uses. Only a symbol: a literal cannot carry a
request value, and a value built inside the function is out of reach of a
check that does not follow it across a call. Only a `db.clj`, which is what
makes it decidable -- the values there are named in the parameter vector a
few lines up.

Off by default, enabled per namespace. Run globally it reports 269 sites, of
which 196 are ids that Malli already constrains to an integer and that
therefore cannot become SQL. Warning about those would bury the 73 that
matter -- strings and enums like `perm-type`, `setting-key`, `namespace`. A
namespace is added to `:config-in-ns` once its own values are marked, so the
list grows with the migration rather than ahead of it.

`metabase.content-translation.db` is the first entry. Removing its marker now
fails the lint.

* Bind a marked kv-arg, which is how queries are actually written

`(t2/select :model/X :locale [:auto/param v])` did not work, and kv-args are
how nearly every query in the codebase is written: 1125 call sites across
`*/db.clj` against 3 that pass a query map. So the marker covered the style
almost nobody uses.

Toucan builds a kv-arg into the where clause before the compile step sees it,
taking the marker keyword for the operator and folding the column in, so
`:locale [:auto/param v]` arrives as `[:auto/param :locale v]`. That arity
was previously rejected as malformed. It is now recognised and rewritten to
`[:= :locale [:param k]]`, which is the comparison Toucan would have built.

A marked kv-arg holding nil still compares as `IS NULL` rather than binding,
matching an unmarked one.

The lint reads the same style now. With it enabled across `src` it reports
1006 sites rather than the 269 it saw when it only walked query maps, of
which 769 are id-shaped and 237 are not -- close to the 78% the project's
estimate predicted, and the 237 are the ones that need a marker rather than
a coercion.

* Pair kv-args from the model, and do not lint written values

Several query fns take an argument before the model -- (t2/select-one-fn
:value :model/Setting :key k) -- so pairing the kv-args from a fixed offset
misaligned them and the values went unreported. They are now paired from the
model keyword, which is where the pairs actually start.

Insert calls are no longer linted for unmarked values. A value written into a
row is not a where-clause value an attacker can turn into SQL structure, and
a define-before-insert hook may read it before the query compiles, so a
marker there breaks the write rather than protecting anything.

* Reject a marked operator form, and check every argument of one

A kv-arg value may itself be an operator -- `:locale [:in [...]]` -- and
Toucan folds that into `[:auto/param :locale [:in [...]]]`, which the kv
rewrite turned into `[:= :locale [:param k]]`. That binds the operator form
as a single value and changes the comparison, so `IN (?, ?)` became `= ?`
against a list. It now throws and points at the working form, which is the
marker inside the operator rather than around it: `[:in [:auto/param vs]]`.

The linter examined an operator's arguments only when there were exactly
two, so `[:between :col lo hi]` and any other arity went unchecked -- a false
negative, which is the expensive direction for this lint. It now takes every
argument after the column, and `between` joins the operator set.

`read-call?` no longer assumes the call head is Named. Kondo always passes a
resolved var so this was unreachable, but the failure would have been a
linter crash rather than one bad file.

Also drops a duplicated paragraph that described the key generation as
gensymed when it uses ThreadLocalRandom, and replaces the ns docstring's
description of a misplaced marker with the statement it actually produces.

* Lint the query shape as well as its values, under one finding

A query written as `:column value` pairs is now reported alongside a value
that reaches a slot unmarked, under a single `:metabase/unsafe-app-db-query`
finding. A namespace opts into both at once, and the pair can be flipped on
globally when every `db.clj` has been converted.

Toucan builds kv-args into the where clause itself, so the query's shape is
not on the page -- there is nothing for a reader, this linter, or the `.sql`
extraction that follows this project to read. Every call has a map form:
`{:where [...]}` for a select, and for `update!` a conditions map, since its
spec takes `conditions-map-or-query?` rather than only pairs.

Checking the shape rather than the pairs' values also removes the reason the
marker had to understand a kv-arg at all. The value check now walks query
maps only.
2026-09-16 03:27:26 +00:00
Alexander Polyankin
48d493c293 Fix the code review findings of the closed argument schemas PR (#82537) 2026-09-15 22:44:21 -04:00
Andrei Chernykh
01f2c4b624 Redesign the LDAP settings page and its group mapping card (#82389)
* Redesign the LDAP settings page and its group mapping card

* Keep the mapping editor fields aligned when a name is rejected

* Merge the LDAP group mapping e2e flows into one

* Address the LDAP page review feedback

* Drop comments that restate the code

* Lock the LDAP attributes card and group the locked cards
2026-09-15 19:37:24 -07:00
Romeo Van Snick
86a761845a Read a card's UI parameters off the question (#82466) 2026-09-16 12:03:10 +10:00
github-automation-metabase
c19c1bfdeb Auto-update documentation for master (#82532)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-16 02:02:55 +00:00
bryan
47644f8e40 Raise describe timeout in handle-circular-refs-test (DEV-2333) (#82540)
`handle-circular-refs-test` has been failing intermittently since #82447.

The last assertion wraps `(umd/describe :metabase.lib.schema/query)` in a
500ms `with-timeout`. That timeout exists as a smoke test against runaway ref
expansion -- before `max-depth` was added to `metabase.util.malli.describe`,
describing this schema took literal minutes. It was never meant to be a
performance budget.

#82447 required closed map schemas in `mu/defn`, which enlarged the schemas
`describe` walks to its `max-depth` of 3. Since expansion cost is roughly
exponential in branching factor, this made the call ~4.5x slower. Measured on
one machine, isolated runs:

    before #82447: 89ms cold, 33-50ms warm
    after  #82447: 232ms cold, 158-193ms warm

Headroom against the 500ms budget dropped from ~14x to ~2.8x. The test is
`^:parallel` and `with-timeout` runs its body in a `future`, so it competes for
the thread pool with every other parallel test; under CI contention ~175ms
crosses 500ms. That is why it presents as flaky rather than consistently red,
and why it showed up on the Postgres 14 and Java 25 suites -- those runners are
slower/more contended, not semantically different.

The describe output itself is unchanged (identical 187-char string), so only
speed regressed, not correctness.

Raising the timeout to 5000ms keeps the runaway-recursion guard meaningful (that
failure mode is minutes, not milliseconds) while surviving pool contention.

Not treating the 4.5x as a perf bug to chase: `umd/describe`'s only production
caller is the error-explanation path in `metabase.api.macros`, reached only
after validation has already failed, and it describes the nested sub-schema
rather than the full query schema. Validation itself is unaffected by #82447
(sub-microsecond before and after).
2026-09-16 01:55:21 +00:00
Romeo Van Snick
980c6b0018 Measure each commit in German and in English (#82536)
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:18:22 +10:00
Fraser
e677b6b267 Use affected-test plans to narrow Loki visual tests (#82050) 2026-09-16 12:37:30 +12:00
Romeo Van Snick
548573f22d Move the public question and the expression editor off the Metadata object (#82313)
* Build the public question from the store

* Pass the expression editor its database, not the Metadata object

* Build the FieldValuesWidget test metadata with createMockMetadata

* Name the endpoints the public question's parameters come from
2026-09-16 08:56:28 +10:00
Aleksandr Lesnenko
00232ddc3a drop disable mcp tools setting (#82533) 2026-09-15 22:00:53 +00:00
bryan
f6caace66a Refuse to save a query handle that carries bound parameter values (#82434)
Refuse to save a query handle carrying bound parameters

A handle minted by execute_sql re-attaches the :parameters it ran with, but
:parameters is runtime-only and serialize-query strips it on the way into
dataset_query. Saving such a handle persisted the query without its filter and
without complaining, so the card returned rows the agent's own run excluded.

Fail closed in resolve-query-handle-for-save!, the chokepoint shared by
question_write, metric_write and transform_write, with a teaching error naming
the concrete alternative.

Closes GHY-4547
2026-09-15 14:51:30 -06:00
technomancy
ba327647bc Update oldest MySQL version to 8.4. (#82507)
According to https://endoflife.date/mysql version 8.0 went EOL in
April. Now 8.4 is the oldest supported release.
2026-09-15 23:34:51 +03:00
github-automation-metabase
dfbff65b08 Update translations 2026-09-15 (#82474)
* Update translation sources 2026-09-15

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-09-15 20:26:58 +00:00
github-automation-metabase
f787cc5b89 Tighten ratchets (#82523)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-15 20:02:24 +00:00
bryan
1b57292d09 Send the SAML SessionIndex in single logout requests (#82441)
* Send the SAML SessionIndex in single logout requests

Metabase's LogoutRequest identified the session to end by NameID alone, which
is the user's email. Auth0 issues a distinct user ID per connection, so one
email can own several sessions and Auth0 cannot tell which to end:

    client_error: No active session(s) found matching LogoutRequest

SAML Profiles 4.4.4.1 makes SessionIndex the element that scopes a
LogoutRequest to a session. Record the SessionIndex the IdP sends in its login
assertion on the session it creates, and send it back at logout.

SessionIndex is per session, not per user, so it lives on core_session rather
than auth_identity, which has a unique constraint on (user_id, provider) and
would let concurrent logins overwrite each other.

A hardcoded SessionIndex was tried once and broke Azure AD (#41600). Only an
index the IdP itself issued is ever sent; sessions without one (every session
predating this column, and every non-SAML session) produce exactly the request
they produce today. The existing saml-logout test, which asserts on the full
signed logout URL for a session with no SessionIndex, passes unchanged.

Requires saml20-clj 4.3.0, which parses SessionIndex out of login assertions
and can emit it in a LogoutRequest.

Fixes #77837

* Send the IdP's NameID, not the user's email, in logout requests

An IdP matches a LogoutRequest on NameID plus SessionIndex. We sent the user's
email as the NameID, but the IdP's NameID is not necessarily an email: Auth0
sends an opaque `auth0|<id>`. A LogoutRequest naming an identifier the IdP never
issued matches no session, so Auth0 answered:

    No active session(s) found matching LogoutRequest

even once we started sending the correct SessionIndex.

Record the NameID and its Format from the login assertion alongside the
SessionIndex, and send those at logout. Sessions predating these columns have a
NULL NameID and fall back to email, so logout keeps working across an upgrade.

Verified end to end against a live Auth0 tenant: the LogoutRequest now carries
NameID `auth0|<id>` with Format `unspecified` and the assertion's SessionIndex,
Auth0 returns StatusCode Success, and the session row is deleted.
2026-09-15 13:56:42 -06:00
Chris Vandoodewaard
6fdc4439da record E2E statistics (#82496)
* add script to upload e2e test timings

* update workflow, add tests

* fix team name
2026-09-15 14:27:09 -04:00
Chris Vandoodewaard
ad51ac8b78 only run some semantic search tests on PRs (#82440)
* only run some semantic search tests on PRs

* add label to force run semantic search tests

* fix whitespace

* trigger ci
2026-09-15 14:26:17 -04:00
Michael Eby
94aea9ae24 docs: 2FA v2 (#81962)
* docs: 2FA v2

* verify admin path and require options

* edit from jeff
2026-09-15 12:46:43 -04:00
Alexander Polyankin
e79f40e3d3 Require typed, closed argument schemas in mu/defn (#82447) 2026-09-15 16:45:35 +00:00
Nick Fitzpatrick
9ce408d114 Uxw 4979 manage unsynced content modal (#81226)
* Manage Unsynced Content modal

* add actions

* Show dependies when attempting to remote sync

* adjust spacing of dependency rows

* handling snippet and dashboard dependencies

* reshaping API response for FE consumption

* save from modal again

* cleanup

* PR Feedback, Design Feedback

* handle instance analytics case

* fixing function string

* Mike's feedback

* fix getIcon hook
2026-09-15 13:16:19 -03:00
Stas Gavrylov
6f423c147d GDGT-3199: Stabilize document comment tests (#82480)
* GDGT-3199: Stabilize document comment tests

* GDGT-3199: Assert editor focus before typing
2026-09-15 18:17:56 +03:00
Stas Gavrylov
7b271d25b6 GDGT-3108: Stabilize document revision history test (#82484)
* GDGT-3108: Stabilize document revision history test

* GDGT-3108: Apply review feedback

* GDGT-3108: Target final toast assertion by text
2026-09-15 18:17:46 +03:00
Will Fry
75398ad620 GDGT-3131: refactor ai usage stats page to use nested routes (#82420) 2026-09-15 15:48:28 +01:00
Stas Gavrylov
34c44f0f7f GDGT-3138: Make inline update rollback deterministic (#82418)
* GDGT-3138: Make inline update rollback deterministic

* GDGT-3138: Clarify reinitialization behavior

* GDGT-3138: Wait for incremental switch readiness

* GDGT-3138: Cleanup
2026-09-15 16:44:05 +03:00
Chris Truter
968b105665 Never send the model an empty user message from a Slack thread (#82443)
A file shared with no text, or a message that only mentions the bot, reached the thread history as an empty user
message, and Anthropic rejects the whole request for it. Name the attachments instead, or leave the message out.
2026-09-15 15:30:26 +02:00
Chris Truter
aecbc3c3cb Warm the Kondo cache so cache-reading hooks run in CI (#82388)
* Warm the Kondo cache so cache-reading hooks run in CI

`hooks/ns-analysis` reads only `.clj-kondo/.cache`, and Kondo writes that cache after every file in a run has
been analysed. `mage kondo` deleted the cache and linted in one pass, so a hook that asks about another
namespace's vars found nothing and skipped its check. `:metabase/prefer-with-dynamic-fn-redefs` therefore never
fired in CI while warning locally against a warm cache.

Lint once and discard the findings before the real pass. `kondo-redundant-ignores` and `kondo-insert-ignores`
get the same warm-up: on a cold cache the former reports needed suppressions as redundant.

Turning the linter on exposes 327 flagged `with-redefs` forms. 316 become `mt/with-dynamic-fn-redefs`. Nine keep
`with-redefs` where the redefined function runs on a thread a dynamic binding does not reach, or on a value the
proxy cannot wrap. Two `.cljc` sites are covered by a namespace entry in config.edn instead, since the macro is
JVM only.

Delegating replacements capture the original with `mt/original-fn`. A bare symbol or `@#'the-var` resolves to
the proxy once a var has been patched, which recurses on the second test in a JVM.

Inline ignore budget 28 to 37, config suppressions 3 to 4.

* Stop counting prefer-with-dynamic-fn-redefs ignores

Genuine cases are common: Quartz workers, pool callbacks, queue delivery workers and values the proxy cannot
wrap all need a root swap. A numeric ceiling adds churn to every PR that meets one. Each ignore still needs a
comment explaining why.

* Harden the Kondo warm-up and keep a primitive fn on with-redefs

`next-page-size` in the BigQuery driver takes and returns primitive longs, so compiled callers invoke it
through `IFn$LLLLL`. The dynamic-redefs proxy is a plain variadic fn, so the conversion threw
ClassCastException, and since patching is permanent every later BigQuery query in the JVM would too.

The warm pass now also lints `dev/src`, which defines vars that `dev/test` redefines. That exposes six sites
in the kondo ratchet tests, now converted. A warm pass that exits with anything but 0, 2 or 3 fails the
task rather than leaving a partial cache. `kondo-updated` warms when no cache exists, and the ratchet tools
warm the roots they lint.

* Only skip kondo-updated's warm pass after a finished one

A cache directory can be partial, left by an editor or an interrupted run, and `kondo-updated` then skipped
warming and missed cache-dependent findings. `warm-cache!` now writes a marker when it finishes, deleting it
first so an interrupted pass can't leave an old one, and `kondo-updated` checks for the marker instead.

* Only let a full warm pass mark the Kondo cache warm

A warm over a narrower set of roots leaves the cache incomplete, so it must not write the marker
`kondo-updated` trusts, nor clear one that a full pass wrote.

* Point the Athena TODO at the macro the test now uses

* Say why kondo-updated accepts a stale cache
2026-09-15 15:30:02 +02:00
github-automation-metabase
5c18aea2c7 Auto-update documentation for master (#82444)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-15 09:14:04 -04:00
github-automation-metabase
18a6ad127c Widen snowplow-micro poll budget to de-flake event assertions (#77383)
Snowplow events are POSTed asynchronously and then ingested by
snowplow-micro before they can be read back via `micro/good`. The
1000ms retry budget in the Cypress helper was too tight for the event
to land on loaded CI runners or under network throttling, producing
spurious "Snowplow retry timeout" failures. Bump the budget to 5000ms;
this only affects how long a failing assertion waits, since matching
events still return early.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-15 14:30:40 +02:00
Fraser
afd98b94a5 Repoint embedding-sdk's ClickObject import to metabase-lib (#82455) 2026-09-15 23:22:26 +12:00
Phoomparin Mano
9a5524063b Infer semantic schema export scope from the data app prompt (#82221)
add scope guidance for agents
2026-09-15 17:01:39 +07:00
Romeo Van Snick
d46d7a47e4 Read the reference lists from their own responses (#82473)
* Drop the unused metadataFields prop from the reference detail pages

* List the reference databases from the fetched response

* Fetch the reference segment list where it is rendered
2026-09-15 19:54:14 +10:00
Romeo Van Snick
8d67bf7e3d Keep the segment retire action closed until its table loads (#82471) 2026-09-15 19:54:00 +10:00
Will Fry
4084c9254b GDGT-3219: fix broken navigation from unsaved sql (#82433) 2026-09-15 08:30:18 +01:00
Romeo Van Snick
5f8f065921 Read the embedding data picker's entities from the store (#82407) 2026-09-15 15:49:11 +10:00
Romeo Van Snick
7ff180c80a Read the table and database from their own requests, not the entities mirror (#82472) 2026-09-15 05:46:55 +00:00
Romeo Van Snick
6964b63d2b Record when the page draws on the second and later visits (#82467)
* Record when the page draws on the second and later visits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Wrap the two long readings for the formatter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:44:59 +10:00
Romeo Van Snick
98486ccd56 Restore the changes #82405 reverted (#82463) 2026-09-15 03:33:50 +00:00
Fraser
7e9e46602f Give types, settings-components and embedding-ee shared-tier levels (#82462) 2026-09-15 14:50:15 +12:00
Fraser
7ea204ed6e Enforce the shared tiers for api (#82460) 2026-09-15 14:50:04 +12:00
Romeo Van Snick
7e36117dbc Read the data selector's entities from the store (#82405)
* Add relation selectors to the metadata store

* Read the data selector's entities from the store

* Take the data selector's entities as plain objects in its consumers

* Format the data selector spec

* Inline the table schema lookup

* Name the data selector's lookups prop entityLookups
2026-09-15 11:59:21 +10:00
Nick Fitzpatrick
72ea707064 Add dataPointsVisible extension to eCharts (#81721)
* useRenderedDataVisibility

* static viz, other adjustments

* remove dead hook

* Split panels, simplification

* handling line series with a range very close to chart boundary, ignore goal line

* return true on traversal if intersection is found
2026-09-14 22:44:43 -03:00
Fraser
781e650301 Move security-center endpoints into enterprise security_center (#79351)
* Move security-center endpoints into enterprise security_center

Moves security-center.ts out of the shared metabase/api module into
enterprise/frontend/src/metabase-enterprise/security_center/api.ts. All
of its endpoints hit /api/ee/ URLs and every consumer lives in the EE
security_center module, so it was an OSS api file with zero OSS
consumers.

The file now uses EnterpriseApi.injectEndpoints, matching the existing
in-module EE precedents (monitor/ai-auditing/metabot-analytics/api.ts,
table-editing/api/index.ts). The security-advisory tag type moves from
the OSS TAG_TYPES to ENTERPRISE_TAG_TYPES alongside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cast test store state for EnterpriseApi selectors

The listSecurityAdvisories endpoint selector is now typed against the
enterprise tag union, while renderWithProviders builds a store typed
with the OSS State. The runtime state shape is unchanged, so the spec
casts through a helper with a justification comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use relative imports for EnterpriseApi inside the enterprise module

security_center/api.ts was reaching its own module through the
metabase-enterprise alias; same-module imports are relative per the
import-style convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Import EnterpriseApi through the metabase-enterprise alias

../api/api reaches the sibling api module, not security_center's own
files, so the same-module-imports-are-relative convention does not
apply here. The alias form matches the other in-module EE endpoint
owners (metabot-analytics, table-editing) and is the specifier the
no-base-api-access rule tracks EnterpriseApi by.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rewrap the cast comment in the promo card spec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Verify security-center mutations invalidate the advisory cache

* Simplify security-center tests and flush query updates

* Match the security-center api spec titles to the folder's should idiom

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 11:37:42 +12:00
Fraser
53f48ed3be Use affected-test plans to narrow frontend unit tests (#82049)
* Use affected-test plans to narrow frontend unit tests

* Pass affected test plans by artifact and reject Jest runner failures

* Extract unit test selection preparation from workflow YAML

* Follow the shared Jest config files in the frontend path filters

* Drop the unused import/no-commonjs directive from the filter spec

* Leave heap retention specs out of the planner's unit list

* Check unit test results in a script and let suite hook failures reach quarantine

* Warn when a narrowed unit test run matches none of Jest's test paths

* Reword the workflow cast comment in the test plan spec
2026-09-15 11:04:56 +12:00
Romeo Van Snick
a0845f5373 Serve the locale catalogues as rspack chunks, remove locale api (#81933)
* Serve the locale catalogues as rspack chunks

The catalogues were fetched from `app/locales/<locale>.json`, a path outside
everything rspack does for an asset. They were unhashed, so `cacheable?` never
granted them far-future headers, and uncompressed, so the server gzipped each
one per request.

Importing them instead puts them through the normal pipeline: rspack hashes each
catalogue, emits it as its own chunk under `app/dist`, writes the `.br` and `.gz`
siblings, and resolves the URL at runtime. A hashed file there is already
cacheable, so no new rule is needed.

Production swaps `devtool` for SourceMapDevToolPlugin, which takes an exclude. A
source map for a catalogue maps JSON onto itself and would add about 29 MB.

* Format the specs the locale change touched

* Keep the catalogues out of the SDK bundle

A context module bundles its whole directory wherever it resolves, so importing
the catalogues added all 36 to the SDK's reachable graph: 66 files to 102, and
its total gzipped size up 275%.

The SDK should not carry them regardless of size. It renders in a customer's
page against whichever instance it is pointed at, so its catalogue has to come
from that instance rather than from whenever the SDK was built.

`locale-catalog` now names how a catalogue is loaded, the way `ee-plugins` and
`sdk-ee-plugins` name their implementations. The app imports from its own
bundle; the SDK bundle config aliases the fetch instead, which also keeps the
context module out of it.

* Let the SDK cache the catalogues it fetches

A catalogue served from `app/locales/<locale>.json` carries no hash, so
`cacheable?` never matched it and the SDK re-downloaded one on every load.

The SDK does not need the app's chunk hash to fix that, and it cannot have it:
the two bundles are separate compilations with their own chunk ids, filename
templates and runtime wrappers, so the same catalogue hashes differently in
each. What it needs is any token that changes when the catalogues do, and it
already has one. The instance serves the SDK bundle, so whatever build produced
it produced the catalogues too, and `EnvironmentPlugin` already compiles that
build's version and commit into it.

The SDK now names its build in the query, and a catalogue request that carries
one is cacheable. Without one it is not, so a caller that omits it cannot pin a
stale catalogue.

* Move the locale loading out of metabase/api

Nothing here calls an API endpoint. The catalogue is a static asset, loaded by
import in the app and by a plain fetch in the SDK, so it sits alongside
`utils/i18n`, which applies what these return.

* Swap the locale loader by path, not by alias

The loader was reached through a `locale-catalog` alias, which had to be
declared in the rspack aliases, tsconfig and jest config before the SDK bundle
could override it: four places to say one thing.

The importer now uses a plain relative import, which every resolver already
understands, and the SDK config keys its override on the resolved path. One
config knows a swap happens, and it is the one doing the swapping.

* Let the SDK load catalogues as chunks too

The SDK fetched catalogues from the instance over HTTP because a context
import pulled all 36 into its bundle. They were never in its initial
payload: an import() context always emits async chunks. What tripped CI
was the gate itself, which is `embedding-sdk-chunked/total`, the reachable
set. All 36 counted, 8.5 MB gzip against a 3.1 MB bundle, so 275%.

The SDK can use the same loader. `sdk-public-path.ts` points
`__webpack_public_path__` at the instance, and the package injects the
bundle as a script tag, so a host app's bundler never sees the import().
Chunks land under `app/embedding-sdk/chunks/`, which the backend already
serves with far-future immutable headers, so the build token and its
`cacheable?` clause are not needed either.

Measure the catalogues out of the reachable totals instead. One loads per
session and none for English, so counting all 36 overstates by 35x, and
their size tracks the translation process rather than any code change.

Drops the loader, its spec, the build-time alias and the backend clause.
The SDK's chunkFilename takes `[name]` so the gate can recognise them.

* Fold the loader into its only caller

Nothing overrides `loadLocaleCatalog` through the build any more, so it does
not need to be its own module.

Drop the SourceMapDevToolPlugin too. It existed to keep source maps off the
locale chunks, which only cost disk in a dev build, and it made `devtool`
conditional to work around the two not being able to run together.

Clear the comments that explained the change rather than the code: the specs
said what the catalogue used to do, and two references pointed at the file
this commit removes.

* Point the locale test hooks at the chunk

The catalogue arrives as a hashed rspack chunk, so the specs that stubbed or
waited on `GET /app/locales/<locale>.json` were watching a request that no
longer happens.

Five e2e specs used the intercept only as a synchronisation point, so they
now wait on the chunk instead. `content-translations` stubbed an empty
catalogue to keep the UI in English while it checks that content is
translated, and a chunk cannot carry a JSON body, so it blocks the chunk. Its
Arabic block asserts English chrome and `AR-` content only, nothing that
needs the catalogue applied.

That leans on LocaleProvider catching a failed load, so cover it: a rejected
catalogue still renders the children.

Also fix the import order the module move disturbed in three consumers.

* Spy on loadLocalization instead of wrapping the module

* Drop a tombstone comment

* Keep the locale set when its catalogue fails to load

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Wait on the language landing, not on the catalogue request

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Keep the locales directory when the translations step clears it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Assert a real Dutch string after the first language switch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Re-render the children once the catalogue is applied

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 06:43:01 +10:00
Fraser
0fd3651033 Move the token refresh hooks into settings (#82402) 2026-09-15 08:22:28 +12:00
Chris Truter
a632ed1799 Interactive module graph viewers (#82375)
* Consolidate the module viewers into one explorer

`./bin/mage modules-tree --html` writes a single page with three views
of the module config: the tree, a coupling ranking, and a dependency
graph around focused modules. `dev.module-explorer/open!` builds the
same page in a dev REPL with namespace-level edges, so the graph can
show which API namespaces each consumer uses.

The graph replaces dev.module-viz and its Jetty server. Source paths,
stats and teams go through the shared resolver, so nested and renamed
modules report correctly. module-team moves into hooks.common.modules.

* Label module counts as used by and uses

* Refine module explorer semantics and state
2026-09-14 20:18:12 +00:00
Fraser
be3443b07d Stop querying from importing metabot (#82401)
* Move MetabotPromptButton into metabot behind a querying plugin slot

* Stop querying reading metabot permissions and sidebar state

* Test the data reference auto-open prop instead of metabot state

* Pass metabot's editor and error UI in as props instead of a plugin slot

* Fix editor specs for the metabot permissions hook and the barrel mock
2026-09-15 08:09:17 +12:00
Andrei Simionescu
02f5855a35 Don't hand raw driver errors to the model when a Slack upload fails (#81202)
Only the upload layer's own 4xx errors reach the model, and everything else gets a generic line. CSV validation errors had no status code, so they throw as 422s now and the upload endpoint answers 422 instead of 500 for a malformed file.
2026-09-14 15:52:09 -04:00
Ben Grabow
30346d6a64 Break requiring resolve cycles (#82011)
* Break requiring-resolve cycles

* Place moved code where its consumers can already reach it

Avoids widening module :api sets for three of the extracted namespaces.
dashboard->resolved-params moves to metabase.parameters.params.
template-tag-parameters and parameter-template-tag? move to metabase.query-processor.card.
Quartz's ConnectionProvider moves to metabase.app-db.quartz; task.bootstrap keeps the connection interceptor.
metabase.app-db.connection, metabase.dashboards.params and metabase.queries.template-tags leave :api.

* Restore comments lost when code moved

The c3p0 deadlock explanation goes back above the `locking` call in prometheus.clj.
The driver-implementation caveats go back on the requires that replaced their requiring-resolve calls.
The PulseChannel-hooks note and the placement rationales for the moved parameter functions come back too.

* Point mcp v2 projections at the moved dashboard->resolved-params

Master's mcp v2 merge landed a caller while the function was moving to metabase.parameters.params.

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2026-09-14 15:46:55 -04:00
Andrei Simionescu
c74356b123 GHY-4070: Update source cards before dependents when a database changes (#82378)
A card's `database_id` is copied from its source card's row, so a dependent rewritten before its
source kept the old database id. The cascade now rewrites cards in dependency order, sources first.
2026-09-14 19:31:49 +00:00
Chris Vandoodewaard
9344c2bd56 Move PR env creation out of run-tests and remove unnecessary workflows (#81814)
* merge containerize-uberjar into uberjar workflow

* big refactor

* move things around, add inputs to fetch-artifact action

* update conditional logic

* clean up comments

cleanup whitespace

* simplify conditional logic

* guard conditional matrix

* review cleanup

* add job names

* cleanup whitespace

* update get-docker-info to resolve repo/tag inputs

* trim trailing whitespace

* add ecr auth if required

* add repo/tag inputs to containerize from uberjar

* use uberjar workflow to push container to ecr

* cleanup pr-env

* add proper label handling to pr-env

* clean up support for ecr

* simplify containerize

* move jar healthcheck into build step

* move containerize back out of uberjar

* review fixes

* a few more fixes

* code review fixes

* cleanup whitespace

* rename workflow to match scheme

* add verification on release, use DOCKERHUB_OWNER var

* fix conditional

* remove whitespace

* end with newline

* add extra guard for labels not pr env related
2026-09-14 14:36:49 -04:00
Aleksandr Lesnenko
a6ce02d1c6 nudge for mbql, fix pagination (#82391) 2026-09-14 13:51:51 -04:00
github-automation-metabase
cbca21ebb4 Tighten ratchets (#82438)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-14 17:42:17 +00:00
Alexander Polyankin
82cc16d2b5 Apply FieldUserSettings on read instead of writing them into metabase_field (#82246) 2026-09-14 17:36:47 +00:00
Andrei Chernykh
14faa1e777 Bump tiptap to 3.31.3 (#82293)
* Bump tiptap to 3.31.3

* Dedupe prosemirror-transform

* Fix document e2e failures after tiptap bump

* Drop PublicDocument unit spec

* Remove dark-mode band around document title
2026-09-14 10:07:32 -07:00
Chris Truter
c829aa7c29 Remove test-only hierarchy from production code (#82394)
* Remove the dispatch-type hierarchy

Its only consumer was the `[:dispatch-type/fn :dispatch-type/*]` method on the ClojureScript
`=?-diff`, which the `:default` method now covers with an `fn?` check. Nothing else passed
`:hierarchy #'u/dispatch-type-hierarchy`, so `:dispatch-type/*` was never reachable as a parent
anywhere else.

`dispatch-type-keyword` still returns `:dispatch-type/*` for unmapped types.

* Rename :dispatch-type/* to :dispatch-type/unknown

The `*` read as a wildcard because the hierarchy made it one. With the hierarchy gone it is just
the value `dispatch-type-keyword` returns for a type it cannot classify, and the old name invited
`(defmethod foo :dispatch-type/*)` as a catch-all that would silently match only unclassified values.

* Tighten the dispatch-type docstring and comment

State the contract and drop the rest: the docstring repeated the `;; we should add more mappings here as
needed` comment sitting three lines below it, and said twice that the keywords have no hierarchy.
2026-09-14 18:17:34 +02:00
lbrdnk
0b8cca6454 Collapse redundant jobs into steps (#82345)
* Collaps redundant jobs into steps

* Set explicit timeout
2026-09-14 17:45:29 +02:00
Arik Aronov
98aa767a98 Fix flaky persist-refresh admin email test by giving it its own logged-in admin (#82413)
* Fix flaky persist-refresh admin email test by giving it its own logged-in admin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Warm babashka classpath once so parallel modules-consistency tests stop racing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Whitelist the babashka classpath warm-up for the parallel-test linter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 18:22:52 +03:00
Mahatthana (Kelvin) Nomsawadi
9f1d48c268 Allow passing dataPicker to EditableDashboard (#82259)
* fix: let EditableDashboard ask for the staged data picker

The picker picks itself: staged above 100 data sources, simple below, and
the simple one only offers tables and models. So passing entityTypes:
["question"] to EditableDashboard did nothing on a small instance, with
no way to opt out — SdkQuestion could already ask for the staged picker,
EditableDashboard could not.

Drop dataPickerProps from the shared dashboard props too. Only the
editable dashboard can reach the query builder, so Static and Interactive
had been inheriting a prop that could never do anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ftDCPjFUZxKwoPda82Nc1

* docs: describe dataPicker in the dashboard guide

The guide's data picker paragraph still described dataPickerProps as
entity types only, and the note added for dataPicker called it the "full"
picker, which is not one of the type's values and is a term the guide
never introduces.

Fold both keys into the intro paragraph, name the pickers as the type
does, and leave only the entityTypes constraint after the snippet. The
defaults also go on the dataPickerProps JSDoc, so they reach the props
reference the guide links to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TacHQDkaEDmdu5jYJUPs1X

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:10:51 +01:00
Will Fry
23f164921b GDGT-2559 fix flaky E2E tests (#82336)
* GDGT-2559 fix flaky E2E tests

* migrate 3 more findByText -> findByRole

* move timeout to shared helper
2026-09-14 16:05:22 +01:00
YB
9e27498bbf [GDGT-3192] audit every action execution in query_execution (#82257)
Records one query_execution row per action execution under new context values action-execute / public-action-execute, so runs appear in the Usage Analytics Query log beside reads.
2026-09-14 22:35:27 +08:00
Chris Vandoodewaard
e0b1eac73d run loki tests through run-tests.yml (#82370)
* run loki tests through run-tests.yml

* add job timeout
2026-09-14 10:23:03 -04:00
Chris Vandoodewaard
c6f84f1abe Move build-scripts.yml jobs into run-tests.yml (#82101)
* Call build-scripts.yml from run-tests.yml

Make build-scripts.yml a workflow_call workflow and invoke it from
run-tests.yml, rather than duplicating its jobs there. The jobs
themselves are unchanged; only the triggers move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* add proper conditions for build-scripts workflow

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:24:54 -04:00
Timofey Kachalov
bbcf4b7d45 Require auth for data app bundle and iframe routes (#82417) 2026-09-14 16:49:33 +04:00
Phoomparin Mano
9c43b06290 Use version-prefixed SDK alpha dist tags (#82419)
uses versioned prefix for alpha dist tags
2026-09-14 11:55:13 +00:00
Timofey Kachalov
8d5cb13553 Exclude data app permission groups from SCIM (#82416) 2026-09-14 15:52:03 +04:00
Will Fry
abebe75424 GDGT-3090: preserve display type when opening a transform inspector card as a question (#82260) 2026-09-14 11:30:13 +00:00
Timofey Kachalov
5529e9aeef Don't fail data apps typed schema generation on bad models (#82071) 2026-09-14 15:16:23 +04:00
Fraser
3dc9a9f23d Carve the redux context out as a lib element (#82410) 2026-09-14 22:04:53 +12:00
github-automation-metabase
9f99cbb9d7 chore(sdk): bump SDK version on master to 0.64.0-alpha.3 (#82398)
* chore(sdk): bump SDK version to 0.64.0-alpha.3 (alpha)

* use 64-alpha dist tag

---------

Co-authored-by: Phoomparin Mano <phoomparin@gmail.com>
2026-09-14 16:32:44 +07:00
Stas Gavrylov
de8bfa2fbb Stop the Data Studio index redirect clobbering a live navigation (#82185)
* Stop the Data Studio index redirect clobbering a live navigation

`/data-studio` is an index route that only knows where to send you once the
`hasSeenGuide` user key-value request lands. The layout renders the nav around
it in the meantime, so a click can start a navigation while the redirect is
still deciding. Because a `route.lazy` destination resolves its module before
the router commits the location, the index route is still mounted in that
window -- and its `<Navigate>` replaced the navigation the user had asked for,
dropping them on the guide instead.

`useIsNavigating` already exists for exactly this window (see
`useDashboardUrlQuery`), so sit it out: whatever is under way unmounts us.

That is what quarantined the transforms permissions e2e test (GDGT-3169): it
clicked "Data transformation" straight after visiting `/data-studio` and then
looked for the enable-transforms page on the guide. The e2e helper now lands on
the redirect target before touching the nav, which also covers the same latent
race in the schema-viewer entry-point test.

* Trim comments to what a reader needs

* Let the Data Studio index redirect settle in the library entry test

* Cleanup

* GDGT-3169: Address PR comments
2026-09-14 11:41:01 +03:00
Romeo Van Snick
1283452019 Read the query builder's source table and TableInfo from the store (#82314)
* Add store selectors for a table's field ids and foreign keys

* Read the query builder's source table from the store

* Read TableInfo from the store

* Link a connected table with the table URL helpers

* Build the connected table link from a dataset query
2026-09-14 11:50:49 +10:00
Romeo Van Snick
2ab5bdad28 Measure each commit once, stamp stats with its timestamp, record total kb, and test different locales too (#82323)
* Measure each commit once, on the first Run tests attempt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Stamp load-time rows with the commit's timestamp, not the day they ran

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Stamp bundle-size rows with the commit's timestamp, not the day they ran

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Record the total kb a page loads beside its script kb

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Measure the page with a German site locale

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Record which locale each load-time row measured

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:26:22 +10:00
Fraser
bb6081b0d6 Enforce the shared tiers for plugins (#82400) 2026-09-14 11:19:53 +12:00
Oleksandr Yakushev
fc81e50c34 [agent-lib] Refactor metabase.agent-lib.representations.* (#82009)
* [agent-lib] Refactor metabase.agent-lib.representations

* [agent-lib] Refactor metabase.agent-lib.representations.repair
2026-09-13 15:40:49 +00:00
Mahatthana (Kelvin) Nomsawadi
b9d217cbb4 Integration branch for Embedding settings reorganization (#80336)
* refactor: rename the embedding hub checklist to setup guide (#79514)

* refactor: rename the embedding hub checklist to setup guide

"Embedding hub" originally meant the whole embedding section; what
shipped under that name in Sept 2025 was only the onboarding checklist,
which users already see as "Setup guide" -- it is the nav label and the
route. Renaming the code to match frees the name for the section the
embedding settings reorganization is building, and stops "embedding hub"
meaning two things.

Directory: frontend/src/metabase/embedding/embedding-hub -> setup-guide,
and the admin wrapper alongside it. Symbols: EmbeddingHub -> SetupGuide,
EmbeddingHubXrayPickerModal -> SetupGuideXrayPickerModal,
EmbeddingHubAdminSettingsPage -> SetupGuideAdminSettingsPage.

Deliberately unrenamed: useGetEmbeddingHubSteps, EmbeddingHubStepId and
the rest of the checklist payload types. Those name the
/api/ee/embedding-hub/checklist contract, and the backend keeps that name
on purpose -- renaming three persisted booleans buys nothing user-visible
and costs a migration.

Frontend-only. No persisted state, no API contract, no route change.

* refactor: finish the setup guide rename in tests and backend

EMB-2208 also covers the spec file names and the two backend comments.
The e2e spec and the SDK embed-setup unit spec move to setup-guide names
and their describe wording follows.

The backend keeps embedding-hub in the setting names, the API path and
the namespace, so both files get a note saying the names track the
Setup guide checklist rather than the embedding hub section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: rename setup guide UI types and hooks

The UI model for the checklist still carried the old word:
EmbeddingHubStep, EmbeddingHubStepId, EmbeddingHubAction,
EmbeddingHubModalToTrigger, the two step hooks, and the home page
component. None of these name the API contract. They are declared in
setup-guide/types, independent of api/embedding-hub.ts, and
EmbeddingHubStepId lists seven step ids where the API's own
CheckListApiStep lists twelve.

api/embedding-hub.ts and everything in it keeps the old name, as does
the embedding-hub-checklist cache tag: those do name
/api/ee/embedding-hub/checklist.

Comments follow. The two above the embedding-hub-*-embed-snippet-created
settings now say the setting names stay while the thing they track is
the Setup guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: reword two missed setup guide mentions

An it() title in the SDK embed-setup spec and a comment above the
checklist keys in the API both still said "embedding hub" where they
meant the Setup guide.

The docstrings that name the API itself are left alone, as is the
:embedding-hub token feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: rename the checklist API client to setup guide

The client module named the endpoint rather than the contract. Nothing
in it is frozen except the URL, which is a literal string: the RTK cache
tag lives only in the frontend store, and the exported types and hook are
ours. So api/embedding-hub.ts becomes api/setup-guide.ts, its types and
query hook follow, and the embedding-hub-checklist tag becomes
setup-guide-checklist.

The surviving url: "/api/ee/embedding-hub/checklist" gets a comment
saying it is the one thing that has to keep the name.

The backend is untouched apart from comments. Its namespace keeps the old
name, so the symbols inside it stay consistent with it -- the seam is
between the two modules, not inside either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* docs: name the setup guide in two comments

Shortening these to "the checklist" dropped which checklist it is, to buy
consistency with the surrounding names. Losing the information is the
worse trade.

In collection.ts there is no longer a mismatch to buy off anyway, since
the tag beside it is now setup-guide-checklist. In api.clj the comment
does disagree with its own namespace, and that is fine: the namespace
docstring already explains the seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: rename the checklist schema and builder

Both are private to the namespace and referenced nowhere else, so
nothing about them is frozen. The frontend type for the same payload is
already SetupGuideChecklistResponse, and two names for one response
across the seam is worse than a symbol disagreeing with the namespace
around it -- that disagreement is explained by the ns docstring.

The namespace, the route, the settings and the token feature keep the
old name, as does the endpoint docstring, which is published in the API
docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: say Setup guide in the two setting descriptions

They said "for tracking in the embedding hub" directly under a comment
saying these track the Setup guide and not the hub section, so the file
contradicted itself and a reader could not tell which line was stale.

Nothing renders these descriptions -- no admin settings widget reads
them, only the settings API payload carries them -- so they are internal
documentation, and the same accuracy argument applies as to any comment.
The setting names still stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: say Setup guide in the endpoint docstring

It described the response as the embedding hub checklist while the schema
right above it is SetupGuideChecklistResponse. The path in the route
docstring and the namespace still name the hub, because those are bound
to the URL.

This string is published in resources/openapi/openapi.json and
docs/api.json. Add the openapi-self-healing label to the PR and CI
regenerates the spec; docs/api.json has no per-PR check and picks it up
on its next regeneration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* refactor: lowercase setup guide in prose

The codebase capitalises "Setup guide" only where it is the literal UI
label, as in EmbeddingNav, and writes it lowercase in running prose --
"Go to setup guide", "Return to the setup guide to continue". The
backend comments and descriptions added here did the opposite, and
api.clj disagreed with itself between one docstring and the next.

Sentence-initial mentions keep their capital, since that is ordinary
sentence case rather than the name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: format the setup guide route imports

oxfmt --check failed fe-lint on the integration branch; the two
setup-guide lazy imports were wrapped in the pre-oxfmt style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGNfTR7qNNvEQczJQDomoi

* feat: add the embedding hub shell and its Get started tab (#80346)

* feat: add the embedding hub shell and its Get started tab

The hub is a top-level section at /embedding, built on AreaLayout like
Monitor and Data Studio rather than on the admin sidebar, with its own nav
and an app-switcher entry. /admin/embedding stays exactly as it is; removing
it is EMB-1526 and comes last.

Get started replaces the vertical stepper with a grid of numbered cards in
two sections plus a useful-links row. Each Fine-tune step is locked by the
feature it needs rather than by one stand-in for "Pro", since an instance can
license SSO without modular embedding. A feature-locked card names no
prerequisite: "Set up SSO to unlock" is a promise the step is one action
away, which is only true above the paywall.

One tab today. The remaining six arrive with their own issues, each adding a
route, an export and a nav entry.

Two shared changes ride in front of it, each its own concern:

* `BaseLink` resolves an explicit `..` against the current route, which the
  hub needs because it mounts admin components under a second base path.
* `AreaContent` grows a `fullWidth` prop, mirroring what the admin settings
  layout already does, for the tabs that are editors rather than settings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp

* docs: correct the embedding hub module tier note

The comment said the hub could drop to feature tier once its components
were lifted out of admin. It cannot: the Permissions and Tenancy tabs
mount admin permissions and admin People a second time on purpose, and
admin keeps its own copy of both, so the hub imports admin-tier code
permanently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* fix: finish the setup guide rename in the Get started page

The rename landed on the base branch, so it never reached files that
only exist here: the Get started spec still imported EmbeddingHubChecklist
from metabase/api/embedding-hub, which no longer resolves, and renaming
useGetEmbeddingHubSteps moved it past useSetupGuideModals alphabetically
and broke the sorted import blocks.

Both edits were made during a rebase, where pre-commit hooks do not run,
which is why lint-staged did not catch either one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnWkk2XpAZurnyELCZFtSE

* feat: let AreaTab render an action as well as a link

The embedding hub's "New embed" control opens a modal rather than
navigating, so it was a Mantine Button with its own height and padding
and did not collapse with the navbar. It now goes through AreaTab, which
makes it the same size as every other rail item by construction.

Omitting `to` renders an UnstyledButton instead of a link. The two forms
are spelled out separately because their polymorphic component types do
not unify.

* refactor: make the UpsellBanner body text optional

The embedding hub's banner is a title and a button with no body copy.
Rendering an empty Text left a stray paragraph, and the large variant
top-aligned the row, so the gem and the button sat above the single
title line.

Without children the body is skipped and the row centres. Every existing
caller passes children, so none of them change.

* fix: return to the setup guide the user actually came from

The guide has three hosts, and every route out of it was hardcoded to
the admin one, so a user who started in the embedding hub was deposited
in admin settings on the way back.

The wizards now navigate relatively, resolving to whichever host mounted
them. The connect-a-database round trip leaves the app entirely, so it
carries the origin instead: the return param holds the path rather than
a bare "true", and the modal navigates there. Links already in flight
carry the old literal and fall back to admin as before.

The home-page stepper returns home; the admin guide keeps the default.

* feat: serve the setup guide checklist without a licence, and add the hub's two steps

The checklist sat behind the :embedding premium gate while the hub that
renders it is admin-only, so an unlicensed admin reached the page and
got a 402. Every step then read as incomplete -- including ones the
instance had genuinely done, which is worse than showing nothing. The
gate is gone; the endpoint checks superuser itself, which the gate had
been providing as a side effect.

The hub's own two steps join the response. Completion belongs to the
instance rather than to whichever host is asking, so both hosts read the
same source: configure-ai moves off a pair of frontend settings reads,
and create-custom-theme becomes answerable at all.

Telling a seeded theme from a user's needs a marker: the names are
stored already translated, and seeding is lazy so the ids are not
ordered. Existing rows are deliberately left false rather than
backfilled -- themes are recent, so the instances affected are few and
already embedding-active, and no retroactive rule is reliable.

* refactor: give the hub its own path, step list and unlicensed layout

Get started was the index route, so /embedding was a prefix of every
future tab's path and the wizards were siblings rather than children.
Both forced special cases in tab selection. It now lives at
/embedding/get-started with the wizards nested under it and /embedding
redirecting there, so tab selection is a plain segment match.

The hub also owns its steps outright rather than borrowing actions from
the shared hook. It already owned the copy, and the half-and-half
arrangement is what forced the action lookup and the omitted-step
handling; both are gone, and so is the URL parameter the hook only had
so the hub could pass its own wizard paths.

Without modular embedding every Fine-tune step is locked and AI is the
one advanced step still reachable, so it moves up into the first section
and the grid fixes two columns. Numbering follows position in the list
rather than arithmetic across three groups, which is what let the order
vary at all.

Also drop the helpers, the fullWidth chain and the SetupGuide prop this
branch introduced but nothing calls yet -- each arrives with the tab that
first needs it -- and order each file from its entry point down.

* refactor: address review findings on the hub shell

Restore the two setup-guide e2e tests the branch had deleted; nothing in
it justified their removal.

Drop the AvailableInEmbedding guard from the hub and Data Studio: it sat
inside a guard built from a selector that already returns false in an
iframe, so no state rendered differently. Add the hub selector spec its
Data Studio and Monitor siblings already had.

Take getIsEmbeddingHubApp as a tenth input selector rather than repeating
the path check inline; the instantiation-depth limit that comment warned
about no longer applies.

Order BaseLink's relative check last so the change is a pure append, and
say what actually goes wrong rather than describing it abstractly.

* fix: keep the checklist from 400ing without a database

has-user-added-database? ended in a when-let, so an instance with
neither a user database nor a sample database got nil rather than
false. The response schema is closed and requires a boolean, so the
whole checklist 400d. Every existing test called (mt/id) first, which
creates a database and hid it.

has-configured-ai? gets the same treatment: it ands two settings, and
a nil from either would fail the schema the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe

* test: cover unlicensed checklist access and the hub's two steps

The 402-without-:embedding assertion described the gate this branch
removed, so it was red. It is replaced by the behaviour that gate
change was for: a 200 with real completion on an unlicensed instance,
paid steps reporting false rather than erroring, and non-admins
rejected now that check-superuser carries the access control the
premium gate used to provide as a side effect.

Adds coverage for create-custom-theme (a seeded theme does not count)
and configure-ai (credentials and embedded Metabot, both halves).

Leaves data-isolation-strategy-test without an (mt/id) call - that is
what caught the nil add-data, so it is the regression guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe

* test: add e2e coverage for the hub's Get started tab

The tab shipped on unit tests alone. These five cover what needs a
real browser or a real backend: the redirect from /embedding, both
step orderings, the wizard's relative back link, feature locking, and
completion arriving from the checklist endpoint unlicensed.

Its own filename rather than sharing the security tab's spec - the two
cover different surfaces, and separate files mean no conflict when the
stack restacks.

Both blocks are @EE. The unlicensed case is an EE build with no token,
not an OSS build: the checklist is served from /api/ee, so on OSS the
route would not exist and every step would read incomplete for a
reason this page is not responsible for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe

* chore: record the embedding-hub module's new dependencies

Regenerated by ./bin/mage fix-modules-config. Both entries come from
the two checklist steps this branch adds: metabot for the AI settings
has-configured-ai? reads, and :model/EmbeddingTheme for the is_default
lookup behind has-created-custom-theme?.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe

* fix: account for embedding_theme.is_default in the serdes spec

serialization-complete-spec-test requires every column on a serialized
model to be declared, and is_default was new. It goes in :skip rather
than :copy: the flag records whether this instance seeded the row, not
anything about the theme itself, so it does not travel. An imported
theme lands as false - the admin's own - which is what importing one
is, and it ticks the create-custom-theme checklist step accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzD6eCBAWFddH31oQg9woe

* refactor: serve the setup guide checklist from OSS

A community build never mounts /api/ee/*, so the checklist endpoint did
not exist there at all -- the four steps its UI actually shows without a
licence (connect a database, create a dashboard, get an embed snippet,
configure AI) had no backend to report them.

Move the namespace to metabase.embedding-hub.api and the route to
/api/embedding-hub/checklist. Two things had to change to load without
enterprise code:

- The JWT and SAML settings ship only with enterprise code, so a missing
  require would fail at load. has-configured-sso? becomes a defenterprise
  whose OSS body returns false; the real implementation stays in
  metabase-enterprise.embedding-hub.api behind :feature :embedding, the
  same token the route was gated on before.

- :model/Tenant, :model/ConnectionImpersonation and :model/DatabaseRouter
  resolve to enterprise namespaces a community build does not ship.
  Guarding each query with the :embedding feature short-circuits before
  Toucan resolves the model, the way has-configured-sandboxes? already
  did with :sandboxes.

Every key stays in the response and keeps its type, so the frontend needs
only the new URL. Enterprise behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5HK8wzZwmAsF9PkgFMfLu

* docs: spell out "embedding hub" in setup guide comments

A bare "hub" reads ambiguously next to the setup guide, which the same
files also talk about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5HK8wzZwmAsF9PkgFMfLu

* refactor: keep the moved checklist code closer to its original

The move added a few things it did not need to: boolean wraps around
expressions the :type :boolean settings already returned as booleans, a
nested cond where a flat one had been, and a named helper where the file
already inlines its feature checks. Undo all of it, so the diff against
the original shows only what moving the namespace forced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5HK8wzZwmAsF9PkgFMfLu

* refactor: address review feedback on the embedding hub

Frontend: object args for useSetupGuideModals, {isLocked, reason} for
locked steps, edition-aware embed snippet copy, no complete badge on
locked cards, margins instead of nested stacks, and trimmed comments.

Backend: gate enterprise-only models on ee-available? rather than the
:embedding feature, and move the checklist api test to OSS.

* refactor: keep the original order in useGetCurrentApp

The hub lives at /embedding, so it never overlaps /admin -- moving the
admin branch to the end changed nothing at runtime and only widened the
diff. Append the hub case instead.

* refactor: rename the upsell utm constants

Renames UPSELL_CAMPAIGN/UPSELL_LOCATION to UTM_CAMPAIGN/UTM_CONTENT and
points SetupGuide at the "setup-guide" campaign.

Also restores the `boolean` wrapper on has-user-added-database?, since the
trailing when-let yields nil on an instance with no sample database and the
checklist response schema requires a boolean.

* fix: unbreak the embedding hub module boundary

The OSS embedding-hub api namespace now requires metabase.config.core, so
the module needs `config` in its :uses set — regenerated with
`./bin/mage fix-modules-config`.

Also reformats admin/routes.tsx to satisfy oxfmt.

* test: drop unit tests the e2e spec already covers

The Get started page's e2e spec walks the real step order, the upsell banner
and a checklist round-trip against a live backend, so the unit tests asserting
the same things were restating a weaker version of it. What is left is what
e2e cannot reach: mixed token shapes, checklist states that need a mocked
response, and the locked-card click gate.

routes.unit.spec.tsx stubbed the guard, the layout, the page and the whole
setup-guide module, which left it asserting that React Router matches a path.
Rewritten to read the route tree as data instead -- no mocks, no rendering,
and it still covers sso-setup, which no e2e visits.

Also drops two tests that asserted a code path the page does not have: it
builds its Fine-tune steps from a local list and never calls
useGetSetupGuideSteps, so no card can be omitted.

* test: drop the hub root redirect test

One line of route config that the Get started e2e spec already asserts
against a real browser. Removing it takes the Navigate lookup, the type cast
and the ReactNode plumbing with it, leaving the path list -- which is the part
nothing else covers.

* refactor: give the New embed action its own nav component

New embed is not a tab -- it renders in the nav's lower slot, outside the
tab list, is never selected and navigates nowhere. Reusing AreaTab for it
meant making `to` optional and branching the render, which put a link/button
split into a component seven other tabs depend on.

AreaNavButton is the button form as its own sibling, so AreaTab goes back
untouched.

* test: name only what the AI checklist test arranges

The block sets the provider key and nothing else -- embedded Metabot being
on is the default, not part of the arrangement. The block below it is what
pins that flag's contribution.

* style: match the New embed button to the design

It carries its own fill at rest rather than lighting up only on hover the
way a tab does, and its label and icon take the brand colour. Centred in
both the expanded and collapsed states.

* style: build the New embed button on the design system Button

The Figma spec is the design system Button -- subtle variant, MD, an `add`
leftSection -- so use it rather than hand-rolling the chrome. The tinted
resting fill comes from Mantine's `light` variant; our own `subtle` renders
transparent, which is a divergence from the Figma token worth raising with
whoever owns the design system.

* test: drop the AreaNavButton spec

It pinned that the component renders a button rather than a link, which was
a real decision while the chrome was hand-rolled. Building on the design
system Button makes it structurally true, so the spec now covers Mantine.
The layout spec already covers the only branch left, the collapsed label.

* test: sharpen the Get started page assertions

The per-feature test framed licensing one feature as a customer shape; it is
only how the gating is shown, since Pro carries everything bar the cloud-only
features. The prerequisite comment now says why an unlicensed step names none:
it is locked regardless. The AI modal is asserted by its title rather than by
there being any dialog at all.

* test: walk both setup wizards to the end

Each wizard now has a test that seeds its steps, reaches the last one and
takes the action that closes it out, asserting the hub is what it lands on.
They end differently: SSO navigates through the anchor itself, permissions
through a navigate("..") handler.

Also corrects the header comment. Both blocks are @EE because the steps are
enterprise-only, not because of where the checklist endpoint used to live --
it moved to OSS, so the old reason no longer holds.

* feat: name the area in the sidenav

The design gives the embedding hub a title beside its logo. AreaLayout
had no notion of one, so it takes an optional title and passes it
through; Monitor and Data Studio are unchanged.

It hides with the navbar, like the header controls do, so a collapsed
rail stays icon-only.

* docs: promise only the basics on the OSS first-embed blurb

AI is not part of a simple embed on OSS, so the pro line oversells what
the steps below it cover.

* perf: load the embedding hub in its own chunk

The hub pages were reachable from the eagerly imported route tree, so
they shipped in the main bundle for every user, embedding or not. Name
each page in an import() and register it with lazy, as monitor and
transforms routes do.

Resolve every loader in the route spec: an import() path and its export
are no longer type-checked, and a typo would first show as a blank page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* test: name the hub spec for the hub, not one tab

Every tab's browser test belongs in one spec, one describe each, so the
file name cannot go stale as tabs land. Its header pointed at a path that
does not exist; the older onboarding checklist is in
onboarding/embedding-homepage.cy.spec.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* fix: import the user selectors from metabase/current-user

getUserIsAdmin and useUserKeyValue moved into the current-user module
on master, so the old paths no longer resolve and the frontend build
fails outright.

* test: mock the LLM endpoints the AI card opens

The AI card opens AIProviderConfigurationModal, which fetches
/api/llm/providers and /api/llm/provider-types. Neither was mocked, so
"opens the provider modal while unconfigured" failed on the unmocked-route
guard -- deterministically, on every branch carrying this spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* style: sort the LLM mock imports

sort-imports orders members by codepoint, so ProviderTypes precedes
Providers. Caught by fe-lint, not by the pre-commit hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* fix: restore connect-database card copy on the get started page

The card had inherited the "Create a dashboard" card's description
from a copy-paste. Restore the wording used by the old setup guide
checklist while the design catches up.

* refactor: replace NumberedCard render closures with a HubCard union

The section arrays only needed to carry which card to render and its
data; wrapping each in a (position) => ReactNode closure just to defer
numbering added a layer with no benefit over switching on a plain
union at render time.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add the embedding hub's Security tab (#80347)

* feat: add the embedding hub's Security tab

The method toggles become rows in one card rather than a card each, in the
design's order: Modular embedding, Modular embedding SDK, Guest embeds,
Full-app embedding. Below the paywall the card lists guest embeds alone --
the others need `embedding_simple`, whose runtime only registers when the
token carries it, so their toggles would be switches that turn nothing on.
The card's heading appears only when it lists more than one method.

SameSite is paid-only for the same reason: it governs the session cookie and
guest embeds never set one, so on a guest-only instance it offers a decision
that cannot affect anything the admin owns.

Published guest embeds are listed when any exist, whatever the toggle says.
That needs the two listing endpoints to answer with embedding switched off,
so they drop `check-embedding-enabled` and keep only their settings
permission check. Publishing and every serving path stay gated -- an admin
who has just turned guest embeds off is exactly the one who needs to audit
what is still live.

Admin's own Security page and its siblings keep their current exports
alongside the new ones; EMB-1526 removes that section and them with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp

* fix: keep the CORS description literal for i18n extraction

babel-plugin-ttag extracts translatable strings at build time, so a jt
template's static text has to be literal in the source. Hoisting the
sentence into a variable and interpolating it made the extractor
hard-fail, which broke verify-i18n-files on this branch and on every
branch stacked above it.

Branch the whole jt expression instead, so each arm carries its own
literal sentence and the HoverCard stays the only interpolation. The
edition-aware copy is unchanged, and the resulting msgid matches what the
base branch already emits, so existing translations still apply.

* refactor: use the shared UpsellBanner on the Security page

Follows the Get started page off the hub's fork of UpsellBanner and onto
the shared component, wired the same way the embedding settings page
already wires it.

* test: scroll the published guest embeds card into view

The hub clips its content and scrolls it internally, so on a CI-sized
viewport the card sits below the fold. Cypress treats an element clipped
by a non-scrollable `overflow: hidden` ancestor as hidden, so findByText
resolved the heading but `be.visible` failed.

Both assertions were identical, so they move into one helper.

* feat: present modular, SDK and guest embeds as one switch

Separate switches add complexity without adding security, since whoever
can embed also controls them, and guest is a property of modular rather
than a method beside it. Full-app keeps its own switch.

The merge is presentational: the switch reads on when any of the three
settings is on and writes all three at once, which is what the backend
flag will do once EMB-2257 replaces them with one. A setting pinned to an
env var cannot be written, so one pinned setting locks the row rather
than letting a partial write recreate the mixed state.

* test: cover the Security page's conditional cards

The spec only exercised the published-embeds card. SameSite and the full-app
origins list are conditional too, and their conditions are the ones a reader
is most likely to get wrong.

* feat: rewrite the embedding legalese modal copy

One switch now covers modular embedding, the SDK and guest embeds, so
the modal no longer needs a per-setting headline. The list becomes one
paragraph, and the actions read Cancel / Agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* feat: name the method each Security setting belongs to

One page now holds settings from several embedding methods, so the
secret key and the authorized origins say which method they apply to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* feat: reword the SameSite cookie description

Drop the method list, split the copy into two paragraphs, and render it
in the secondary colour the other rows inherit from SettingHeader --
the nested Text was resetting the colour to primary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* feat: reword the embedding methods rows

The paid row leads with what modular embedding and the SDK let you
build. OSS has one method, so its row says Enable embedding rather
than naming guest embeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* test: match the renamed authorized origins title

The widget title now names the method, so the exact-text query no
longer matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* docs: source the merged switch rationale

The reason was real but read as an assertion. Name where it came from:
Alessio's call in the Embedding Settings Reorganization thread, carried
by EMB-2257.

Trim the OSS paragraph to its first sentence. Which methods the token
feature gates is visible in the code right below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* perf: load the hub's Security tab in its own chunk

Follows the lazy route registration the Get started tab now uses, so the
Security page does not pull the admin settings widgets into the main
bundle. Resolve every loader in the spec: an import() path and its export
are no longer type-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* refactor: drop the EmbeddingSecurityWidgets wrapper

It had one consumer. The admin Security page never used it -- it renders
the two widgets itself -- so the extraction shared nothing, and living in
that page's file made the two look like one component renamed. The hub
page composes the widgets directly instead.

Restores the admin page's spec, which had been repointed at the wrapper
and so left the shipped page uncovered. The widget behaviour it asserted
is already covered by the hub page's own spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* refactor: compose the methods card the way peer settings do

Pass the heading to SettingsSection instead of rendering a Title inside
it, and drop the Stack the section already renders. Label the row's
switch, which every other embedding toggle does.

Say where the merged-switch decision came from rather than asserting it,
and mark mergedSettingKeys as temporary at both ends -- EMB-2257 replaces
the fan-out with one setting and deletes the prop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* refactor: title the published embeds card as its source page does

SettingTitle, matching the admin Guest embeds page this card came from,
which also gives the section its anchor id.

Drops embeddingHubAppearance -- no appearance route exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* fix: write every merged setting from the terms modal

The modal intercepts the first enable, and it wrote only the setting it
was handed. So the very first time an admin turned the merged switch on
-- the path every new instance takes, since the terms start unseen --
enable-embedding-sdk and enable-embedding-static stayed off while the
switch read on.

The existing test missed it by pinning show-simple-embed-terms to false,
which routes around the modal. Covered now at that seam, and end to end
through a real click and a fresh load.

The browser test has to start from embedding off, which the default
snapshot does not: it enables sdk and static, and the merged switch reads
on when any of the three is on. Publishing a dashboard is itself gated on
guest embeds, so the sibling test enables them before it publishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* fix: check embedding and embedding_simple features independently

Modular embedding and full-app embedding are separate token
features. Gating full-app on embedding_simple hid it for accounts
that only have the embedding feature.

* test: cover embedding_simple and embedding features independently

The methods card now checks the two token features separately, so
the test setup needs to mock embedding alongside embedding_simple
and cover the full-app-only case the merge previously collapsed.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: consolidate the three embedding method settings into one flag (#80348)

* feat: back the merged embedding switch with one setting

Modular embedding, the SDK for React and guest embeds become one
setting, `enable-embedding-modular`; full-app keeps its own. The
previous commit merged them in the UI only, writing all three at once,
which left the gates reading something the switch did not say.

There is no migration. Env beats the app db when a setting resolves, and
a Liquibase changeset cannot see env, so a migration would mishandle
env-pinned instances -- and writing env-derived values into the db would
freeze them, breaking the operator's env var as the live control.
Instead the getter falls back: until the new setting is set it reads as
the OR of the three it replaces, taken through normal resolution so
their env vars count. Nothing is rewritten, so nothing is lost going
back.

The fallback keys off the raw value's *source*, not its value, because
an unset boolean reads as its `false` default. Without that, "an admin
turned it off" and "nobody set it" are the same state, and turning
embedding off bounces back on from a legacy value.

Analytics use `simple_event` rather than a new `embed_share` version:
its event field is a closed enum, and Snowplow is on its way out in
favour of Metaplow, so versioning it is throwaway work.

The three old settings are deprecated rather than removed. Retiring
their env vars is a breaking change, documented separately.

* chore: mark the replaced embedding settings deprecated in 0.65.0

The embedding hub is expected to ship in 65. Tracked in the Notion
release-projects entry; if it slips, these three move with it.

* fix: report embedding telemetry from the setting that is now written

Nothing writes the three replaced settings after the cutover, so on any
instance using the merged toggle they stay false forever while embedding
is on. Left alone, every field derived from them would report embedding
as disabled and adoption would look like it collapsed.

Both call sites now read the setting that is actually written. The field
names stay as they are so existing reports keep resolving, which does
mean three fields that used to differ now always agree -- there is no
distinction left to report.

internal-stats reads through `setting/get` rather than
`get-value-of-type`, which bypasses custom getters and would have missed
the fallback to the replaced settings. Its output feeds metering-stats,
so this one is a billing signal, not only analytics.

* test: point the embedding tests at the merged setting

The three settings the modular flag replaces still exist, so these kept
passing through the fallback getter rather than through the gate they mean
to exercise.

The migration test keeps the legacy keys: it asserts on rows a historical
changeset wrote, not on current behaviour.

* refactor: read the merged embedding setting everywhere

Frontend and e2e still keyed off the three settings the modular flag replaces.
Repointing them is what makes the flag the single source of truth rather than
one more value alongside them.

The legacy keys stay in the settings types: the backend still defines them.

The legalese modal loses its setting argument. It now writes the merged flag
and clears both terms settings -- either one left on would re-open it. Turning
guest embeds on now goes through the modal, which it did not before; that
follows from guest being folded into modular.

* fix: keep the embedding terms modal paid-only

Consolidation pointed the OSS row at the same setting the modal guards,
so an OSS admin enabling guest embeds would have been asked to accept
terms that do not apply: the copy is about modular embedding and the SDK,
and about shared accounts as unfair use of a paid seat, while guest
embeds serve anonymous viewers over a signed JWT and OSS has no seats.

Gate the terms path on the embedding_simple token feature. Paid still
sees the modal on off -> on, OSS never does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErtCFepL9GnTTBE3QwSmiH

* test: start the embed modal card test from embedding off

The merged setting reads as the OR of the settings it replaces, which
the pro snapshot leaves on, so the card the test looks for never
rendered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* fix: stop reading the deprecated embedding settings

Marking the three replaced settings deprecated made every remaining
call site a lint failure. some-embedding-enabled? reads the merged
setting, which answers the same until an admin writes it. The env-var
sync tests exercise the deprecated settings on purpose, so they keep
them behind an ignore, and the budget records the ten new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVkbXYuZi14Cpnv22AGGJu

* test: drive the guest embed tests from the static flag

These exercise the /api/embed endpoints, which guest embedding gates.
Pointing them at the merged enable-embedding-modular made them assert
against a flag wider than the behaviour under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* fix: collapse the merged embedding flag in the e2e setup

Renaming the three settings to one left each call site written twice.
In embed-parameters the two calls disagreed -- simple true, static false
became modular true then modular false -- so the whole spec ran with
embedding off.

The two auth-switch tests now share one setup by design, since one flag
governs both options. Their titles said otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* feat: consolidate the two embedding terms settings into one flag

The merged toggle governs modular embedding and the SDK, so the terms
behind it are one gate, not two. The switch was OR-ing the pair inline
while the wizard and theme editor still wrote only the simple one, which
re-opened the legalese modal over terms the admin had accepted.

show-modular-embed-terms reads as that same OR until an admin answers it,
so an upgrade cannot drop an acceptance, and every accept path now writes
the one setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* docs: explain the merged embedding stats read and e2e setup

Note why the stats namespace reads through setting/get (to run the
fallback getter), and what the merged e2e specs start their embedding
state from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* test: give the embedding settings specs one enable option

The helper carried isEmbeddingSdkEnabled and isEmbeddingSimpleEnabled,
OR-ing them into the single setting that now backs the switch. They also
doubled as the embedding_sdk and embedding_simple token features, which
stay separate, so the two uses are split rather than merged: one
isEmbeddingEnabled for the setting, token features passed on their own.

Restores envSettingKeys over a boolean, since the card still houses a
second row whose setting can be pinned to an env var, and drops terms
fixtures from specs that cannot reach the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* test: cover the merged legalese behaviour

One flag means a state that could not happen before: embedding on with
the terms never accepted. An OSS admin turns guest embeds on, sees no
legalese because none is shown below the paywall, and upgrades.

The admin toggle prompts on off to on only, so it stays quiet there by
design; the embed flow asks for the terms instead. Both halves are
covered, along with the other states the flow section renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* test: drive the embed setup helper from one enable flag

The helper OR-ed simpleEmbeddingEnabled and guestEmbeddingEnabled into
the single setting the switch now backs -- two options that cannot be set
independently once there is one flag. They collapse into one
modularEmbeddingEnabled, which drives the setting and the token feature.

The two guest AppearanceStep tests kept modular enabled via the simple
option anyway, so dropping the guest option leaves their behaviour (dim
state follows the static terms) unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eWbw5TiosJfLk9uLwKLgH

* test: point the embed API tests at the merged flag

check-embedding-enabled now gates on enable-embedding-modular, but this
namespace still set enable-embedding-static at four sites, so the whole
embed API suite 400'd with "Embedding is not enabled." in every driver
job. Its siblings were all repointed; this file was missed.

The merged getter's legacy fallback does not cover it: it only fires
when get-raw-value-source is :default, and that checks the db value
first, so any earlier test writing the merged flag kills the fallback
for the rest of the JVM.

* fix: add modular embedding to the embed_share enum

The merged setter emits modular_embedding_enabled/_disabled, which was
not in the embed_share iglu enum. The comment called that acceptable
because Snowplow is being retired, but public-sharing-embed-flow's
afterEach asserts zero bad rows, so it failed CI.

Adds schema 1-0-3 with the two values, the same way simple_embedding_*
was added in 1-0-2, and bumps the version the tracker sends. 1-0-3 also
drops the stale self.version 1-0-1 inherited from 1-0-2.

* test: add the merged terms setting to the serdes baseline

show-modular-embed-terms is :export? true, so it appears in a fresh
export but not in the checked-in baseline, failing
baseline-comparison-test on every app-db and the EE backend suite.

enable-embedding-modular needs no entry -- it is :export? false.

* fix: track the modular embedding toggle as a simple_event

embed_share's `event` is a closed enum, so modular_embedding_* needed a
new schema version. Cutting 1-0-3 was a worse trade than it looked:
schema->version holds one entry per event type and the four legacy
toggles all fire embed_share, so pointing that entry at an unpublished
1-0-3 makes the URI unresolvable and drops every embed_share event in
prod, not just the modular ones.

simple_event takes a free-form event string, needs no schema at all, and
track-event! already fans out to Metaplow, which is where embedding
analytics are headed anyway. This is what EMB-2257 specified. The context
the enum-typed event carried moves into event_detail, the way the SDK
bundle already sends it.

Reverts the 1-0-3 schema added in a5cc6db545d.

* refactor: name the legalese gate for what it decides

The flag reads as show/do-not-show the legalese, so the toggle guard and
the fallback test labels say that, and the labels spell out that
unaccepted is true and accepted is false.

* test: enable preview embed tests through the merged flag

The local helper turned on enable-embedding-static, which only reaches
the endpoint guard through the legacy fallback. An earlier test in the
namespace leaves enable-embedding-modular explicitly set -- with-temporary-setting-values
captures through the getter and restores through the setter -- so the
fallback is gone by the time these run and they 400.

* chore: budget the vector-less kondo ignores this branch adds

The embedding settings sync tests read deprecated settings on purpose,
and use the vector-less ignore form at ten new call sites. Master's
ratchet schema has no :all entry, so seed one at the actual count.

* test: justify the vector-less kondo ignores in the settings tests

Master's kondo-ratchet-test now requires an explanatory comment on any
ignore whose linter is outside :comment-exempt, and :all is not in that
set. Each of the ten sites says why it reads a deprecated setting.

* test: use the map form for the deprecated-var ignores

The ten ignores in the settings tests were the vector-less
#_:clj-kondo/ignore form, which suppresses every linter on the next
form. Naming the rule means the suppression says what it suppresses.

:all drops out of the ratchets entirely (that budget was all this
branch's) and :deprecated-var goes 86 -> 96. Numbers regenerated on
this branch with ./bin/mage fix-kondo-ratchets --seed :deprecated-var.

* test: accept the merged terms in the get-code spec

show-modular-embed-terms falls back to the legacy sdk and simple
settings until an admin accepts it, so clearing only the simple one
left the terms still owed and the Agree CTA still reachable.

* refactor: drop the replaced embedding settings from the types

enable-embedding-sdk, -simple and -static and the two legacy terms
settings have no frontend reader left now that everything goes through
enable-embedding-modular and show-modular-embed-terms.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: point the merged-in EMB-2309 test at the modular flag

Master's new test used simpleEmbeddingEnabled, which the three-way
embedding flag consolidation renamed to modularEmbeddingEnabled. The
merge took both sides cleanly, so nothing conflicted.

* feat: add the embedding hub's Authentication tab (#79509)

* feat: add the embedding hub's Authentication tab

A JWT-only view of the authentication settings. Admin's authentication page
does not change -- this is a second view onto the same settings, so the admin
JWT form is mounted as-is rather than forked, which brings user provisioning
along with it.

Below it, a banner links out to the other SSO methods. After the admin
embedding section goes, this is the hub's only route to SAML, OIDC, Google
and LDAP, so the link is functional rather than decoration.

Below the paywall the tab shows an upsell. `UpsellSSO` could not be reused:
it is the narrow sidebar card from admin authentication and hardcodes a
242px max width, so it renders squished as a page-level upsell. The new one
follows the shape the shipped embedding upsells already use, which is also
why `UpsellCardContent` gains an `image-panel` variant -- a single bordered,
tinted panel, for artwork that carries its own framing or none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp

* fix: reword the upsell trial copy

Design's wording for the shared upsell card: "Get a 14 day trial of this
and other Pro features." The theme upsell spec asserted the old string,
so its trial-copy test could not pass and its negative assertion had
quietly stopped matching anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* feat: give the Authentication tab its three states

The tab showed the JWT form unconditionally. Per the design and
Alessio's answers it now has three: the form once JWT is configured, a
card pointing at admin when only SAML is, and an empty state offering
to set JWT up when neither is.

Only JWT and SAML count as configured SSO, matching has-configured-sso?
on the backend. They are the only methods an embed can drive --
/auth/sso resolves to one or the other, and the SDK ships a jwt and a
saml auth path and nothing else. OIDC, Google and LDAP sign people into
Metabase itself and stay linked out.

Configure JWT goes to the guide's SSO wizard rather than dropping the
admin into a bare form: it is the flow Get started already uses and it
covers the endpoint and the test too. It is mounted under this tab, so
the wizard's back link names Authentication and finishing it returns
here.

The page also waits on the settings query the JWT form gates on, so the
banner no longer renders beside the form's spinner, and the banner is
dropped from the SAML state where the card already links to the same
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* refactor: show the plain JWT form when nothing is configured

The tab offered an empty state that sent the admin into the setup
guide's stepped SSO wizard, mounted under this tab. Alessio's call:
show the standard JWT page here and leave the stepped flow to the setup
guide, which already owns it -- he is also redesigning the JWT page, so
a bespoke entry point here would be short-lived.

The SAML-only state is unchanged: it still shows its card and its link
to admin, because JWT cannot be configured from this tab in that case.

Removes the empty-state card, the nested authentication/sso-setup route,
its URL helper, and the back-link label prop that only existed to name
this tab from inside the wizard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* fix: drop the JWT form's own heading in the hub

The hub's Authentication page already renders a page title, and mounting
the admin JWT form inside it added a second one -- two h1s stacked, the
lower reading just "JWT", which the design does not have.

The form now takes an optional `title`, defaulting to the same "JWT" it
always used, so the admin route at settingsRoutes.tsx renders unchanged.
The hub passes `null`; SettingsPageWrapper already skips the heading box
when the title is falsy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* refactor: give the hub its own upsells module

data-studio and monitor each keep their upsell pages in an `upsells/`
directory with a BaseUpsellPage that assembles DottedBackground,
LineDecorator and UpsellCardContent. The hub had no such module, so its
SSO upsell hand-assembled the same four imports from admin/upsells.

Mirrors that structure: BaseUpsellPage holds the assembly, and
AuthenticationUpsellPage is left with the token gate and the copy. The
card is aligned to the page heading the hub renders above it, where the
other two centre theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* refactor: trim the authentication page's comments

Cuts the JWT/SAML rationale down to the fact that matters -- embedding
supports only those two -- and the loading note to why a second
subscriber is not a second request.

Drops `?? false` on the `jwt-configured` read. It is optional in
metabase-types/api/settings.ts where its SAML peer is not, but the only
use site treats undefined and false alike, and nowhere else in the
codebase coalesces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* refactor: drop the image-panel upsell variant

The variant existed so the SSO illustration could sit in a single
bordered panel rather than the nested double frame `image-card` draws.
That is a change to a component every upsell in the product renders, for
one card, so it waits until design confirms the frame is wrong.

The SSO upsell falls back to `image-card` in the meantime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* chore: drop the unused embeddingHubAppearance url

Nothing calls it and `/embedding/appearance` has no route. It belongs to
the Appearance tab, which lands with EMB-1532.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* refactor: stop parameterising the SSO upsell's source

One call site, one value. The data-studio upsell pages hardcode their
campaign and location the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* test: cover the Authentication tab end to end

Three cases the unit tests cannot reach: that the mounted admin JWT form
actually writes its settings from the hub, that a SAML-only instance gets
the Admin card instead, and that an unlicensed one gets the upsell with
nothing configurable under it.

The heading assertion is here rather than in a unit test because the
duplicate title only shows once the real form renders inside the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* test: tighten the Authentication tab's e2e assertions

Matches the JWT field by its full label rather than a loose regex. The
asterisk is part of it: Mantine renders the required marker inside the
label element, and getByLabelText matches the label's textContent, which
includes aria-hidden nodes.

Also asserts the upsell's own copy on an unlicensed instance, rather than
only that the form is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdJ7UHzej4V16G2W6AP3hQ

* fix: lazy-load the JWT form in the hub through the route slot

#80268 (merged from master) moved SettingsSAMLForm/JWTForm/OIDCForm from
eager PLUGIN_AUTH_PROVIDERS components to lazy route slots, dropping the
eager JWT assignment this page's direct render relied on. Loading the same
route slot with React.lazy keeps the hub on the auth-jwt chunk #80268 split
off instead of re-adding an eager import.

* fix: add a background wash to the SSO upsell illustration

Matches the illustration background the Localization and Tenants
upsells use, so the four embedding hub upsell tabs read consistently.

* test: trim duplicate copy assertions from the Authentication e2e spec

Card copy and the SAML-configured link's href are already unit-tested
next to the component. What is left is what needs a real browser --
confirming the upsell (or the SAML card) renders in place of the JWT
form.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: enable modular embedding in new param-values test

Master added card-param-values-native-card-without-parameters-test
enabling only enable-embedding-static. This branch consolidated the
three embedding method settings into one flag, so that setting alone
no longer enables the static embed endpoint and the request 400s with
"Embedding is not enabled". Use the file's own
with-embedding-enabled-and-new-secret-key! macro, as every other test
here does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ

* fix: carry the design scale renames onto the stack

GDGT-2486 (#80481) renamed the spacing and radius scales in place,
mechanically remapping every call site that already existed on master.
These files were authored on the stack before that landed, so their
tokens are stale -- still-valid names that now resolve to different
sizes.

Spacing xs->xxs, md->lg, lg->xl, xl->xxl; radius sm->xs, in both the
component props and the CSS module custom properties. Spacing sm is
unchanged at 8px.

Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

* feat: add the embedding hub's Permissions tab (#79510)

* feat: add the embedding hub's Permissions tab

The admin permissions editor, mounted a second time under the hub. Admin
permissions does not change, and the tab shows whatever the admin editor
shows -- five tabs when tenants is on -- rather than a narrowed copy, so
there is no forked component to keep in sync.

Making it mountable elsewhere needs a base path the URL builders can read.
That is a module-level value rather than a React context, because
`getDatabaseFocusPermissionsUrl` and `getGroupFocusPermissionsUrl` are
called from redux selectors and thunks, which have no component to read a
context from. Only one editor renders at a time, and the write happens
during render, so the first child render already builds correct URLs; admin
wraps its own routes too, so the value is restored whenever admin's editor
mounts after the hub's.

Three navigations inside the editor built their target from a literal
/admin/permissions rather than that base path, so picking a collection or
switching between the databases and groups views threw a hub user into
admin. They go through the base path now, like every other permission URL.

The tab carries the hub's page title, which is load-bearing: without it the
editor's tab row starts at the top of the area, where "Permissions help"
sits under the app switcher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHCSv6oQRLUUGo34V1eEDp

* fix: restore the permissions base path from admin's own routes

The hub's Permissions tab set a module-level base path during render and
relied on its unmount effect alone to put admin's back. Navigating from the
tab to /admin/permissions runs admin's render before that passive cleanup,
so admin's breadcrumb selectors could build hub links on their first pass.

Admin now wraps its permissions routes in `PermissionsBasePath` as well, so
the default is set during admin's own render. That is what the tab's commit
message already claimed.

Also drops the tab's explicit `<Outlet />`: `PermissionsBasePath` defaults
its children to one, and adds the spec the component never had -- the
set-during-render contract is what the admin wrapper rests on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* fix: make the embedding hub's Permissions tab full-width

The 800px content cap and AreaContent's own padding were both still
applying, so the permissions editor never reached the design's full
width. Adds a fullWidth escape hatch to AreaContent and wires it
through for the permissions route only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa

* fix: make the permissions editor's selection color configurable

Admin's purple (navbar-admin*, protected from whitelabeling) was
hardcoded into AdminTreeNode's selected row and EntityNameLink,
leaking into the embedding hub's blue-branded mount of the same
editor. Both now read from a context, defaulting to admin's purple;
the hub overrides it via PermissionsBasePath's new selectionColors
prop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa

* fix: derive every permissions accent color from one value

Three separate tokens (background_surface-brand-strong/-subtle, plus
navbar-admin's own triplet) made the override fragile, and missed the
"You've made changes" bar entirely -- it stayed on accent7 regardless.
Collapses the context to a single ColorName; every consumer (tree
selection, entity link, edit bar) derives its own shade from it via
color()/alpha() instead of picking a second unrelated token. EditBar's
admin boolean becomes an accentColor prop so permissions can plug into
the same lever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa

* fix: move the permissions accent color to CSS Modules, off fabricated tokens

The prior fix introduced custom CSS variables (--permissions-tree-node-*)
that read as new design-system colors, and extended two emotion files
that are deprecated in favor of CSS Modules. Replaces both:

- The context now carries a plain isHub boolean (utils/is-hub.tsx)
  instead of a color value, mirroring base-path.ts's own module-singleton
  shape where that pattern is actually needed (redux selectors/thunks
  with no component tree) -- isHub's consumers are all components, so it
  stays a React context.
- AdminTreeNode moves out of FilterableTree.styled.tsx into a plain
  component + AdminTreeNode.module.css, selecting between colors the
  design system already owns (navbar-admin/accent7 vs core-brand/brand)
  rather than a fabricated bridge variable. Selectors use `li.<class>` --
  a bare class ties in specificity with TreeNodeRoot's own emotion rule
  and the winner would depend on injection order.
- EntityNameLink and EditBar keep their original, byte-identical emotion
  styling for admin; the hub override is a plain inline style / an
  existing `admin` boolean, never a new token.
- EditBar itself is migrated off emotion entirely (EditBar.module.css),
  since it was being touched anyway and the pattern was already
  deprecated.
- EmbeddingHubLayout's full-width routing moves from an inline pathname
  check into a `fullWidth` field on each tab's config, so adding another
  full-width tab is a one-line change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa

* test: cover navigation through every level of the hub's permissions editor

Clicks through Data (Groups and Databases views), Collections, and
Application, drilling one level into each, then asserts the URL and nav
never leave the embedding hub.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFKUvQLEMmyQ43m5VPtLMa

* refactor: rename permissions is-hub to isEmbeddingHub and rework tree colors

Full name makes the flag's purpose explicit at call sites. Selection
colors move off ad-hoc emotion overrides onto AdminTreeNode.module.css,
using the data-css-specificity-hack convention and core-white instead
of mantine's color token.

* fix: route tenant collection navigation through the hub's base path

Both pages hardcoded navigate("/admin/permissions/...") in their
item-select handler, so selecting a shared or tenant collection while
inside the embedding hub kicked the user out into full admin. Use
getPermissionsBasePath() like every other permissions sub-page, and
restore the e2e test's item-click coverage for both tabs.

* test: assert permissions editor path by prefix, not substring

cy.url().should("include", ...) matches anywhere in the URL. Anchor on
pathname with a leading-^ regex instead, which also makes the paired
not.include("/admin/permissions") checks redundant -- an anchored
/embedding/permissions match already rules that out.

* refactor: simplify permissions color override and drop redundant comment

Use the raw CSS var string directly in PermissionsTable's inline style
instead of the color() helper -- no compile-time benefit was needed here
that outweighs the extra import. Also trims a comment on
PermissionsBasePath's isEmbeddingHub prop that only restated its name.

* refactor: derive the hub color switch from the base path, drop the context

isEmbeddingHub tracked its own state alongside base-path.ts's, always set
together by the same caller -- a second variable with no way to drift
independently. Collapse it into a plain derived check instead of a
context/provider pair.

Also drops PermissionsBasePath's now-redundant admin wrapper: reaching
admin permissions with the hub's base path still set requires a hard
navigation (typed URL, or this app's back button), which reinitializes
the module and needs no explicit reset. Only the hub's own mount/unmount
still needs to touch the singleton.

* test: cover the new table-under-database permissions route

getAdminPermissionsRoutes() gained a route for tables directly under a
database, without a schema segment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh

* fix: carry the design scale renames onto the stack

GDGT-2486 (#80481) renamed the spacing scale in place, mechanically
remapping every call site that already existed on master. This page was
authored on the stack before that landed, so its token is stale -- a
still-valid name that now resolves to a different size.

Spacing md->lg.

Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

* fix: align permissions chrome with the hub app switcher

The hub places its app switcher 3.5rem from the right edge and its page
title 2.5rem from the left, but the permissions toolbar's help button
ended 1.75rem from the right and the save banner sat at 2rem on both
sides, so neither lined up on the hub's full-width permissions page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoNBWFMfL68mPmofjNq66x

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add the embedding hub's Tenancy tab (#79511)

* feat: restyle the tenants upsell for the embedding hub

New copy, a new illustration and a left-aligned card, from the embedding
hub's design. `UpsellTenants` is shared: admin People renders the same card
above its people list, so this changes that surface too. That is intended --
the two should not diverge -- and it is split out here rather than buried in
the Tenancy tab commit, which is what actually needs it.

The old artwork had this card as its only reference, so it goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* feat: add the embedding hub's Tenancy tab

A second view of the tenant surfaces. Tenants live under admin People and
stay there, so the route fragment is mounted a second time rather than
copied. What that costs is not the routes -- it is the roughly thirty
hardcoded /admin/people/tenants strings inside them, which now come from a
`useTenantUrls` context that admin and the hub each provide.

A context rather than the module-level value the Permissions tab uses: these
builders are only ever called from components, while
`getGroupFocusPermissionsUrl` is also called from redux selectors and thunks
that have no component to read a context from.

The tab renders conditionally rather than reusing `createTenantsRouteGuard`:
that guard redirects to /admin/people when tenants are licensed but
`use-tenants` is still off, which is exactly the state a hub user reaches by
clicking a tab that is always visible. So: no licence shows the upsell,
licensed-but-off shows the enable card, both on mounts the fragment.

The enable card opens the user-strategy modal in place rather than routing to
it, because that modal route hangs off the tenants listing, which this branch
of the tab does not render -- the URL would match and nothing would mount.

Two width interactions look like the hub's fault and are not: the listing
centres itself with an auto inline margin, which beats align-items on a flex
item and collapses it, so it gets a child with an explicit width; and its
empty state capped at 700px, narrower than every sibling tab, so the cap is
now the hub's column width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* fix: remove tenant/user URL helpers left dead by createTenantUrls

useTenantUrls and useUserUrls replaced every caller of these; nothing
references them any more.

* fix: fix tenancy page UTM campaign, dead route fallback, and a typo

The docs link used a different UTM campaign than the rest of the hub
(embedding_hub vs embedding-hub), so its clicks were not attributed
with the others. The routes.tsx fallback for PLUGIN_TENANTS.tenantsRoutes
could never render, since EmbeddingHubTenancyPage only mounts an Outlet
once tenants are licensed and enabled. Also fixes a missing "you" in the
enable-tenancy copy, and tags the new unlicensed e2e block @EE to match
its siblings in the file.

* refactor: trim comments to their one-line why

No behavior change; shortens comments that had drifted into restating
the code or arguing a design decision instead of stating the reason.

* fix: add breathing room around the tenants upsell diagram

The diagram frame filled the full width of its bordered box with no
margin, unlike the design. Shrinks the frame ~15% and recenters it
within the same canvas so the artwork carries its own padding, the
same way the shape geometry already did vertically.

* fix: revert tenancy oss e2e block to match its true siblings

An earlier commit tagged this block @EE to match the file's older
unlicensed-SSO convention, but the tab's actual siblings (appearance,
localization, nav) all use a plain "oss" describe with no tag. Matches
those instead.

* test: enable tenancy through the UI instead of the API in e2e

The test only asserted the tenant tabs appear once use-tenants is
true, setting it via H.updateSetting. Drives the enable card and the
Pick a user strategy modal instead, so at least one path through the
actual UI is exercised.

* refactor: rebuild tenant URLs as a module singleton, not a context

createTenantUrls returned a whole object of URL builders bound to a
basePath threaded through React context, so tracing what a given call
produces meant following the provider tree. Every real consumer of
these URLs is a component, same as the permissions editor's, and that
one already solves this with a module-level base path plus plain
exported functions (admin/permissions/utils/base-path.ts). Matches
that shape here: setTenantsBasePath/getTenantsBasePath/
resetTenantsBasePath, called imperatively by whichever host mounts
the tenant routes.

This also removes the two describe/hook layers (TenantUrlsProvider,
useUserUrls) that existed only to have somewhere to call a hook from.

* refactor: keep tenant/user URL functions at their original names

newTenant, editTenant, editUser, deactivateUser, etc. now read the
shared base path directly instead of being deleted and rebuilt behind
a hook -- same signatures every other call site already expects.

* refactor: revert tenant-aware components to plain function calls

These only became components or gained a hook call because a hook
needed a component to run in. Reading the new singleton is a plain
import, so UnsubscribeUserMenuItem goes back to a bare element, and
ReactivateUserButton/TenantsListingEmptyState/
TenantsSummaryOnboardingStep/EditUserStrategySettingsButton go back
to their original arrow-function bodies.

EmbeddingHubTenancyPage sets the base path itself now, the same way
EmbeddingHubPermissionsBasePath does for the permissions editor.

* test: assert on real content after enabling tenancy in e2e

Replaces the toast assertion (flaky) with the tenants listing's own
empty-state copy, and adds a check on the OSS upsell's description
alongside its title, so the test confirms the actual tenants-specific
content rendered, not just any upsell.

* refactor: consolidate tenant URL builders

Reuse the permissions editor's existing setPermissionsBasePath/
getPermissionsBasePath instead of a second, parallel permissionsPath
field on the tenant URL module. Use namespace imports (Urls/TenantUrls)
consistently instead of importing individual functions, matching the
convention already used throughout the urls files. Inline the two
single-use tenantGroupUrl/tenantPeopleUrl helpers at their call sites
now that getTenantsBasePath is exported directly. Preserve the
declaration order of the pre-existing tenant URL functions and the
Urls import alias rather than reshuffling them.

* fix: keep tenants upsell centered in admin

The embedding hub's restyle of UpsellTenants (left-aligned, no
horizontal padding) was applied unconditionally, so admin People's
existing render of the same card lost its centered layout. Add an
align prop, defaulting to admin's original centered layout, and have
the hub opt into the left-aligned one.

* refactor: rename tenant-urls to base-path, matching its sibling

Drops the tautological spec — the round-trip it tested has no
equivalent for the permissions editor's base-path.ts, and the real
behavior is covered by EmbeddingHubTenancyPage's own spec and e2e.
Aligns file name, accessor order, and comment placement to that
sibling, and un-exports ADMIN_TENANTS_BASE_PATH now nothing outside
the module reads it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* refactor: use named imports for getTenantsBasePath

The Urls/TenantUrls namespace alias is reserved for actual urls.ts
files; common/tenants isn't one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* fix: rename UpsellTenants's location prop to source

Avoids confusion with router location; matches
UpsellEmbeddingTheme/UpsellBigCard/UpsellPill's existing convention
of taking a source prop and mapping it to the tracking location
internally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* refactor: simplify EmbeddingHubTenancyPage from self-review

Reuses SettingsPageWrapper instead of a hand-rolled Stack/Title shell,
matching the Authentication and Security tabs. Renames the local
`root` to `tenancyBasePath`, marks the docs-link icon aria-hidden so
its accessible name is just "Documentation", and simplifies the
route-guard comment. Also asserts on the upsell's description text,
not just its title.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* fix: match tab boundaries in TenancyTabs, not raw string prefixes

pathname.startsWith(tab.to) has no segment boundary, so a tenant
whose id starts with "groups" or "people" (:tenantId is a free-form
path param) would light up the wrong tab. Reuses EmbeddingHubLayout's
isUnder, which already solves this for the hub's own top-level tabs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* fix: extend timeout for the post-apply tenants-listing assertion

Enabling tenancy through the modal chains two network round trips
before the empty state can render: the use-tenants setting update's
own refetch, then the tenants list query once the listing mounts.
That's tight against Cypress's 4s default under CI load — reproduced
twice in a row in CI, not flaky-random.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* fix: match the tenants-listing empty state text with a regex

The prior timeout bump was the wrong diagnosis: the empty state's
copy is interpolated with three inline anchors (jt`...`), so no
single DOM node's text content ever equals the plain string exactly
-- Cypress's default string matcher requires a whole-node match, not
a substring. The CI screenshot showed the text rendered correctly;
the assertion just couldn't find it. A regex matches the substring
instead, which is why it kept failing even at 10s.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146A1MwgsMWTE4xdRyfdLpY

* fix: align the Tenancy tab's title-to-card gap with its siblings

Dropped the gap="xl" override on SettingsPageWrapper so it uses the
same default gap as the other embedding hub tabs, and added the same
background wash the other upsell illustrations use.

* test: trim duplicate copy assertions from the Tenancy oss e2e test

Card copy is already unit-tested next to the component. What is left is
what needs a real browser -- confirming the upsell renders in place of
the real tenants surfaces.

* fix: keep the reset-password link under the tenants path

UserSuccessModal is mounted both by admin People and by the tenants
routes the hub's Tenancy tab renders, so the hardcoded /admin/people
link sent a hub user into admin. Urls.resetPassword already branches
on isInternalUser via getTenantsBasePath.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014H8Ma3D2dsLvaz4PkpsxTS

* test: count the new table-under-database permissions loader

* fix: carry the design scale renames onto the stack

GDGT-2486 (#80481) renamed the spacing scale in place and dropped the
shadow scale's md key, mechanically remapping every call site that
already existed on master. This page was authored on the stack before
that landed, so its tokens are stale.

Spacing md->lg, lg->xl, xl->xxl, and shadow md->sm -- the shadow scale
has no md key any more, so that card was rendering no shadow at all.

Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add the embedding hub's Appearance tab (#79512)

* feat: add the embedding hub's Appearance tab

The embedding theme editor plus the appearance settings that show up inside
an embed, on one tab -- there is no separate Themes tab, so the standalone
nav item goes away and its upsell moves here.

Both listing and editor now take their base path from the host so their links
resolve under whichever section mounted them. The listing's own "Themes"
heading is behind `showHeading`, which admin keeps and the hub turns off:
the hub page is titled "Appearance" and puts the grid under a "Themes"
section of its own, and two headings would stack.

The two halves are technically gated on different features -- themes on
`embedding_simple`, the appearance settings on `whitelabel` -- but they
arrive together in practice, so the tab is gated as a whole rather than
building a partial state nobody will see.

`EmbeddedAppearanceSettings` is a deliberate subset: loading message, the
Metabase links toggle and the two empty-state illustrations. Logo, favicon,
login and landing illustrations, instance colours, fonts and application
name stay in admin, since they are not what an embed shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* docs: trim self-review comments in the Appearance tab

Cut the multi-paragraph JSDoc/inline comments down to their factual
core, and drop soft pointers to "the design" in favor of stating the
actual reason.

* fix: lowercase "full-app embedding" in the Appearance banner copy

The phrase sits mid-sentence, not as a heading, so it shouldn't be
capitalized like the Full-app embedding settings card title.

* fix: nest the theme editor route under appearance/theme

New and edit theme URLs were /embedding/appearance/new and
/embedding/appearance/:themeId, colliding with the tab's own path
segment convention. Nest them under a theme segment instead, and make
the editor full-width like the rest of its side-by-side layout instead
of capped to the tab's default 800px.

* fix: match the full-app appearance banner to the design spec

Was background-info with a border; the Figma spec calls for
background-brand with no border.

* fix: link the Get started theme card to the hub's Appearance tab

The Appearance tab now exists, so the card no longer needs to fall
back to the admin theme listing.

* fix: update routes spec for the appearance/theme route nesting

The spec still expected embedding/appearance/:themeId from before the
theme editor route moved under embedding/appearance/theme/:themeId.

* fix: use SettingsPageWrapper for the Appearance tab's title

Was a bespoke Stack + Title with a hardcoded 2.5rem gap, which put the
tab out of step with the other embedding hub tabs' title-to-card
spacing.

* test: count the new table-under-database permissions loader

* fix: load the hub theme editor lazily, behind its token gate

The theme editor route mounted its page eagerly via `element=` with a
static import, instead of the `lazy=` pattern every sibling route
uses. That pulled the EAJS runtime into the main bundle rather than a
lazy chunk, and skipped the `embedding_simple` token gate the
Appearance page applies before showing the editor, so a deep link to
/embedding/appearance/theme/:themeId opened it on plans that cannot
use it.

Add EmbeddingHubThemeEditorPage as a lazy-loaded wrapper that checks
the gate and redirects to the Appearance tab when it's absent, and
route :themeId through it via `lazy=` like the rest of the hub.

Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* fix: carry the design scale renames onto the stack

GDGT-2486 (#80481) renamed the spacing scale in place, mechanically
remapping every call site that already existed on master. This page was
authored on the stack before that landed, so its tokens are stale --
still-valid names that now resolve to different sizes.

Spacing xs->xxs, md->lg.

Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

* fix: full-bleed the embedding hub's upsell pages

Review on #79513 flagged the Localization upsell's dotted background as
cropped. Root cause: BaseUpsellPage rendered inside the hub's 800px
capped column, so DottedBackground had no room to fade before its edge,
and LineDecorator's oversized bleed lines got clipped mid-fade.

Fixes it by making a gated tab's whole content area full-width (same
mechanism Permissions already uses) and moving BaseUpsellPage's title
inside the dotted field, so the field owns the whole tab instead of
sitting under a separately-rendered heading. Title and card now share
one fit-content, centered box so their edges always line up, with an
explicit 160px gap between them matching the design.

Authentication, Tenancy and Appearance each get their own hub-local
upsell page through BaseUpsellPage now, instead of reusing admin's
UpsellTenants/UpsellEmbeddingTheme -- those stay untouched, since they
still serve admin surfaces directly and get removed once EMB-1526 drops
the admin embedding routes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WHaGA2f7AsXGCdQmBzsHw

* fix: redirect the bare appearance/theme path back to Appearance

Visiting /embedding/appearance/theme with no theme ID matched the
theme route segment but had nothing to render, leaving a blank page
(flagged in review on #79512). Adds an index redirect back to the
Appearance tab, matching the pattern the hub root already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WHaGA2f7AsXGCdQmBzsHw

* fix: flow the full-app appearance banner's link inline

The Admin link sat in its own nowrap Group next to the sentence, so on
narrow screens the sentence wrapped but the link got pushed onto its
own line at the far edge instead of flowing with the text (flagged in
review on #79512). Renders the whole banner as one Text with the link
inline, so it wraps as a normal sentence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WHaGA2f7AsXGCdQmBzsHw

* test: cover the hub's theme basePath and the editor guard

Three gaps on the Appearance tab. Clicking an existing theme card was
only proven for the New theme button, so a regression sending card
clicks to the admin route would have passed. The theme editor's token
guard had no test at any level, and its route table redirects the bare
`theme` path regardless of plan, so the deep link needs a themeId to
exercise the guard at all. The branding section asserted only the
loading message, not the two illustration widgets beside it.

Also renames the "oss" block to "unlicensed": it runs on the EE jar
with no token, which is the unlicensed state, not an OSS build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZe6AQywXThKuntLQtLt1B

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: use the embedding db helpers in the snowplow payload

The master merge kept two t2/count calls in the modular-embedding event
while dropping the toucan2 require, so the namespace failed to compile.

* test: use the merged embedding flag in the collection browser spec

#80881 landed on master using enable-embedding-sdk, which this branch
folds into enable-embedding-modular.

* feat: add the embedding hub's Localization tab (#79513)

* feat: add the embedding hub's Localization tab

Content translation renders only from the admin embedding section today --
it is not on the admin Localization page -- so this is the one tab that is a
genuine move rather than a second view. The instance-wide localization
settings stay in admin and are not surfaced. Admin's copy goes away with the
rest of the admin embedding section, in EMB-1526.

Below the paywall it shows an upsell, which keeps the hub's tab set the same
seven on every edition. Its copy and illustration are placeholders: no Figma
frame exists yet, so the artwork is a generated placeholder captioned as one
rather than another upsell's, which would make a design that does not exist
look settled. Both are drop-in replacements.

The two specs that need the finished nav -- seven tabs in design order, and
the gem on each paid tab -- land here, because this is the first branch where
all seven exist. They get a nav describe of their own rather than riding
along in a tab's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* fix: correct stale comments on the Localization tab

The page comment claimed admin already loses content translation, but
this PR only adds the hub tab -- the admin route is removed separately
by EMB-1526. The upsell comment pointed at a gitignored plan file that
no reader of the merged code can ever open.

* refactor: reuse BaseUpsellPage for the Localization upsell

Was a hand-duplicated copy of the same DottedBackground + LineDecorator
+ UpsellCardContent shape AuthenticationUpsellPage already builds via
BaseUpsellPage, which is also why its title-to-card gap didn't match
its sibling tabs. Also finalizes the description copy, which was a
placeholder.

* fix: replace the Localization upsell's placeholder illustration

Real artwork in place of the generated placeholder box.

* fix: correct backwards duplicate/link wording in Localization's doc comment

The comment described 'duplicates the admin embedding section's UI'
and 'linking to it' the wrong way round: rendering the same settings
component in two places is reuse, and what the other tabs do instead
is genuinely link out to admin.

* test: move nav-completeness and copy assertions from e2e to unit specs

Two e2e tests --reaches all seven tabs from the nav, shows every tab
with a gem on the paid ones-- duplicated coverage routes.unit.spec.tsx
and EmbeddingHubLayout.unit.spec.tsx already had (route existence,
page resolution, nav link order) or could cheaply get (the gem
indicator, now covered by a new EmbeddingHubLayout test). Dropped both
and moved the Localization tab's body copy assertion into its own
unit spec, alongside the title check it already had.

* test: cover the licensed Localization tab in e2e

The tab branches on the content_translation token feature, but only the
upsell half was covered. Add the pro case asserting the dictionary
configuration mounts, and rename the sibling block from "oss" to
"unlicensed" — it runs on the EE jar with no token, which is the
unlicensed state, not an OSS build. Matches the naming the get started
and authentication blocks already use in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZe6AQywXThKuntLQtLt1B

* fix: keep the permissions base path across hub tabs

Both the Permissions tab and the Tenancy tab declare the hub as the
permissions editor's URL host, and both reset that on unmount. React
renders the incoming tree before cleaning up the outgoing one, so
navigating between them reset a value the other had just claimed, and
the tenants listing's permissions link fell back to the admin route.

Comparing the stored value could not fix it: both hosts store the same
string. Track which host claimed last with a useId, and let only that
one release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

* style: wrap the permissions base path setter signature

oxfmt requires it under the line limit.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: update the JWT signing key flow to the approved design (#81068)

* feat: add the embedding hub's Localization tab

Content translation renders only from the admin embedding section today --
it is not on the admin Localization page -- so this is the one tab that is a
genuine move rather than a second view. The instance-wide localization
settings stay in admin and are not surfaced. Admin's copy goes away with the
rest of the admin embedding section, in EMB-1526.

Below the paywall it shows an upsell, which keeps the hub's tab set the same
seven on every edition. Its copy and illustration are placeholders: no Figma
frame exists yet, so the artwork is a generated placeholder captioned as one
rather than another upsell's, which would make a design that does not exist
look settled. Both are drop-in replacements.

The two specs that need the finished nav -- seven tabs in design order, and
the gem on each paid tab -- land here, because this is the first branch where
all seven exist. They get a nav describe of their own rather than riding
along in a tab's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* fix: correct stale comments on the Localization tab

The page comment claimed admin already loses content translation, but
this PR only adds the hub tab -- the admin route is removed separately
by EMB-1526. The upsell comment pointed at a gitignored plan file that
no reader of the merged code can ever open.

* refactor: reuse BaseUpsellPage for the Localization upsell

Was a hand-duplicated copy of the same DottedBackground + LineDecorator
+ UpsellCardContent shape AuthenticationUpsellPage already builds via
BaseUpsellPage, which is also why its title-to-card gap didn't match
its sibling tabs. Also finalizes the description copy, which was a
placeholder.

* fix: replace the Localization upsell's placeholder illustration

Real artwork in place of the generated placeholder box.

* fix: correct backwards duplicate/link wording in Localization's doc comment

The comment described 'duplicates the admin embedding section's UI'
and 'linking to it' the wrong way round: rendering the same settings
component in two places is reuse, and what the other tabs do instead
is genuinely link out to admin.

* test: move nav-completeness and copy assertions from e2e to unit specs

Two e2e tests --reaches all seven tabs from the nav, shows every tab
with a gem on the paid ones-- duplicated coverage routes.unit.spec.tsx
and EmbeddingHubLayout.unit.spec.tsx already had (route existence,
page resolution, nav link order) or could cheaply get (the gem
indicator, now covered by a new EmbeddingHubLayout test). Dropped both
and moved the Localization tab's body copy assertion into its own
unit spec, alongside the title check it already had.

* test: cover the licensed Localization tab in e2e

The tab branches on the content_translation token feature, but only the
upsell half was covered. Add the pro case asserting the dictionary
configuration mounts, and rename the sibling block from "oss" to
"unlicensed" — it runs on the EE jar with no token, which is the
unlicensed state, not an OSS build. Matches the naming the get started
and authentication blocks already use in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZe6AQywXThKuntLQtLt1B

* fix: keep the permissions base path across hub tabs

Both the Permissions tab and the Tenancy tab declare the hub as the
permissions editor's URL host, and both reset that on unmount. React
renders the incoming tree before cleaning up the outgoing one, so
navigating between them reset a value the other had just claimed, and
the tenants listing's permissions link fell back to the admin route.

Comparing the stored value could not fix it: both hosts store the same
string. Track which host claimed last with a useId, and let only that
one release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

* style: wrap the permissions base path setter signature

oxfmt requires it under the line limit.

* feat: update the JWT signing key flow to the approved design

Replace the single setup modal with the approved flow: a read-only
generated key shown in "Create a secret key", and a confirm step before
regenerating an existing key.

- read-only key box on one line, icon-only copy button
- "Regenerate key" is now a secondary button
- regenerating asks "Delete key and generate a new one?" first, then
  shows the new key in "Store your new key"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSPdZEZk6NgAeB9C21YWDA

* test: cover the JWT secret key create and regenerate flows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSPdZEZk6NgAeB9C21YWDA

* fix: always show a freshly generated JWT signing key

Reading the token from the RTK Query cache surfaced the key generated
the last time the modal was open, so a user could copy or save a key
that was about to be replaced. Hold it in local state instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSPdZEZk6NgAeB9C21YWDA

* refactor: reuse ConfirmModal and CopyTextField props in FormSecretKey modals

RegenerateKeyConfirmModal duplicated ConfirmModal's title/message/
confirm/cancel layout instead of using it, and SecretKeyModal hand-rolled
the CopyButton/Tooltip/ActionIcon markup that getCopyTextFieldProps
already provides. Also aligns SecretKeyModal's body spacing, text color,
and cancel button styling with ConfirmModal's conventions.

* fix: prevent dismissing FormSecretKey modals via X, outside click, or escape

The generated key can't be shown again, and the regenerate confirmation
is destructive, so both must be dismissed through an explicit button
rather than accidentally closed.

* fix: don't autofocus the secret key modal's readonly input

Mantine's modal focus trap was landing on the readonly key field by
default, which triggers its focus border color, not any override on
the field itself. Autofocus the confirm button instead, and exclude
the input from focus entirely since it's disabled until the token
loads.

* style: drop border/padding overrides on the secret key input

Mantine's own Input[data-variant="default"] rule already sets a
neutral border color at the same specificity, so the custom border
was a coin-flip fight against the design system rather than a real
override; the default padding already accounts for the copy button's
rightSection too. Keep the CSS specificity hack for the properties
that do need overriding (radius, background, monospace font).

* test: use full-string queries for the new secret key assertions

Only the lines this diff added; existing regex queries elsewhere in
the file are left as-is.

* refactor: rename SecretKeyModal.tsx back to SetupKeyModal.tsx

Keeps the reviewer-facing diff on this branch to a plain modification
instead of a delete+add across an unrelated PR. The component itself
stays named SecretKeyModal.

* refactor: name the has-secret-key check instead of checking value directly

value is a raw secret-key string; using its truthiness inline four
times reads as cryptic. hasSecretKey names what's actually being
checked.

* test: assert the copy button writes the token to the clipboard

The old test only checked the button rendered with no visible label,
which didn't verify copying actually works.

* fix: stop naming an embed type in the shared enable CTA

Enabling embedding via SSO or Guest now flips the same toggle
(enable-embedding-modular), so wording like "enable modular
embedding"/"enable guest embeds" implied a type-scoped action that
no longer exists. Reworded the CTA sentences and toasts to generic
"embedding"; the two terms flags and their legalese stay separate
since SSO and Guest/AGPL are gated to different editions and never
shown to the same admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdRnBCJB84JkuZwLrdhsJp

* test: follow the new secret key modal flow in the JWT spec

The set-up modal's confirm button is now "Create", and regenerating
goes through a confirmation modal ("Delete key and generate a new
one?") before the store-new-key modal, so the spec's single "Done"
click no longer matched either step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh

* test: expect the generic wording in the guest enable CTA

146acfbb6ea reworded the shared CTA to "enable embedding" when the
simple-embed feature is unavailable, which is the case for the guest
card in both the EE-without-token and OSS/Starter specs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh

* test: use the renamed Create button in the hub's JWT spec

The hub's copy of the admin JWT form goes through the same secret key
modal, whose confirm button this branch renamed from Done to Create.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh

* fix: import useMetadataToasts from its new home

Master moved the hook out of metabase/metadata/hooks into
metabase/common/hooks; this file was the last caller left behind.

* fix: close the secret key modal when generation fails

The modal turns off its close button, escape and click-outside so an unsaved
key cannot be dismissed by accident. On a failed `random_token` request no key
ever arrives, so the confirm button stays disabled and the modal has no exit at
all. Closing from the catch keeps the guard for the case it was written for.

Reported by @lorem--ipsum on #81068.

* fix: carry the design scale renames onto the stack

GDGT-2486 (#80481) renamed the spacing and radius scales in place,
mechanically remapping every call site that already existed on master.
This modal was rewritten on the stack before that landed, so the tokens
on its new markup are stale -- still-valid names that now resolve to
different sizes.

Spacing md->lg and lg->xl on the modal, radius md->sm on the secret key
field's CSS module.

Verified against the mapping #80481 applied across its own 1339 files.
CI cannot catch this, so it is a deliberate pass rather than a fix for
a failing check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

* fix: keep the guest CTA a whole sentence per edition

Interpolating a translated noun into a translated sentence leaves
translators with "To continue, enable %s and agree to…" and no way to
inflect the surrounding words. Give each edition its own complete
sentence instead.

The "agree to the usage conditions" branch loses its edition split
altogether: show-static-embed-terms is force-false on Pro, so
termsAccepted is always true there and the branch never matches.

Updates the three negative assertions in the OSS/starter spec that
still named the old copy, and which no longer guarded anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

* refactor: leave the modular section comment alone

The file's only change was a reworded doc comment, with no code under
it -- and the new wording invented "SSO-specific terms", which do not
exist: the SSO radio gates on show-modular-embed-terms, the Guest radio
on show-static-embed-terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

* fix: let the keyboard reach the generated secret key

tabIndex={-1} took the field out of the tab order, so the key could not
be read or selected without a mouse. It was not what kept autofocus off
the input: useFocusTrap returns the [data-autofocus] element first, and
that is already the confirm button.

Records why the modal has no escape hatches, and what a hung request
costs, next to the props that cause it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

* test: cover that the secret key modals resist Escape

All three modals disable the close button, outside clicks and Escape so
a shown key cannot be lost, and nothing asserted it -- the PR asked
reviewers to check it by hand instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

* style: drop redundant rules on the secret key modals

ConfirmModal already renders its confirm button feedback-negative, and
flex-wrap: nowrap is the default for the flex container the rule itself
declares. Trims the comment above the specificity hack to what the
rules under it actually do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdtGmjnS93RHcNfu5mMUA7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Remove the embedding admin settings and relocate its components (#81018)

* feat: delete the admin embedding route section, redirect to the hub

Deletes the /admin/embedding route subtree now that the embedding hub
replaces it, and adds redirects from every old path to its /embedding
equivalent so no bookmarked or linked URL breaks. Removes the
"Embedding" entry from the admin sidebar/paths list, and the three
route-chrome files that back the deleted section directly
(AdminEmbeddingApp, EmbeddingNav, the admin-side setup-guide page) --
none of them are imported anywhere else.

Sweeps every hardcoded /admin/embedding/... reference this forces:
the SSO wizard's troubleshooting copy, the setup-guide default return
paths, the related-settings links, and the entire e2e suite (15 spec
files). Two e2e specs that only asserted on the deleted admin sidebar
are removed; their edition-specific content checks (OSS/starter
upsell + CORS visibility) are ported into the hub's own security e2e
spec, updated to match its actual (redesigned) upsell banner. Adds
dedicated e2e coverage for the redirect map itself, including the two
backward-compat chained-redirect cases.

Moves the version-pinning card to admin General, the one piece with
no hub-tab home -- and fixes a dropped `embedding_simple` gate in the
process: the original render path required embedding_simple AND
embedding_sdk AND is-hosted?, and the first port of this card only
carried the latter two.

Part 1 of 2 for this PR -- part 2 relocates the components this
section leaves stranded under admin/, once this part isolates the
routing diff.

* refactor: relocate stranded embedding admin components

Moves the components left homeless by commit 1's admin/embedding route
deletion: the theme editor/listing cluster to embedding/themes/, and
the hub-only settings widgets to embedding/settings/.

Along the way, moving the settings widgets surfaced that shared/embedding
cannot import feature/admin (shared is the architectural foundation and
never imports up). SettingHeader, SettingsSection, and AdminSettingInput
had to come out of admin/ too, into a new shared settings-components/
module, so the widgets are self-sufficient. All ~114 consumers across
admin/, enterprise/, and transforms/ were repointed to the new module.

The rest of the old EmbeddingSettings/ cluster (EmbeddingSettings.tsx,
GuestEmbedsSettings, the EmbeddingSecuritySettings wrapper,
EmbeddingSdkSettings, NewEmbedButton, SharedCombinedEmbeddingSettings)
had zero remaining consumers once commit 1 landed, so it's deleted
rather than left as dead weight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* fix: carry the theme editor's app-tier boundary override to its new path

ThemeEditor already had its own app-tier module-boundaries.mjs entry,
predating this PR: it previews the live embed through the app-tier
embedding-iframe-sdk runtime, which has real module-load side effects
(custom element registration, a window.metabaseConfig watcher) and
can't be split into a shared-tier reusable piece.

The previous commit moved the folder but left that entry's pattern
pointing at the old admin/embedding path, so it silently stopped
matching and the files fell through to the general shared/embedding
classification -- caught by a boundaries/element-types lint failure.
Updates the pattern to follow the move, no suppression needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMyCTcwtcQsGggkCL9QmGp

* fix: update EmbeddingHubTenancyPage's SettingsSection import path

SettingsSection moved to metabase/settings-components in this branch's
relocation commit; this import was missed.

* fix: correct theme-editor redirect target to include the /theme segment

/admin/embedding/themes/:themeId redirected to /embedding/appearance/:themeId,
but the theme editor is actually mounted at /embedding/appearance/theme/:themeId
(routes.tsx nests it under a "theme" path segment). The old target matched no
route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdRnBCJB84JkuZwLrdhsJp

* fix: update EmbeddingHubAppearancePage's SettingsSection import path

The move of SettingsSection to metabase/settings-components missed this
call site, so the Appearance tab failed to resolve the module at runtime.

* refactor: drop the unmounted InteractiveEmbeddingSettingsCard

Removing the embedding admin settings page deleted the only render site
for PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettingsCard, leaving the
slot written but never read. The origins widget it wrapped is still
mounted, by the hub's Security tab.

* fix: return setup wizards to the guide host they came from

The permissions and SSO wizards left the guide with a relative "..",
which resolves against the route, not the host, so a user who entered
from the embedding home page landed in the hub. Carry the host in the
existing returnToEmbeddingSetupGuide param instead, so it also survives
a reload mid-wizard.

* fix: remount a redirect whose target is itself a redirect

Without a key, React reuses the RedirectRoute instance across a
sibling redirect chain, so the first target stays frozen in state and
/admin/embedding/modular never reaches /embedding/security.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh

* fix: repoint DatabaseCachingEditor at the relocated SettingsSection

Master added this importer after the module move, so it still pointed
at metabase/admin/components/SettingsSection, which this branch
deletes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8T4ZvTbdRbF85B98maGNh

* fix: redirect retired embedding paths to the hub

Six legacy redirects still pointed at /admin/embedding and
/admin/embedding/guest, which this branch turns into redirects
themselves. Chained that way the second RedirectRoute reused the
first's instance and froze on the first target, so they were made to
work by keying the shared redirect() helper -- changing behaviour for
every redirect in the app to serve six call sites in one file.

Point the six straight at /embedding/security instead and restore
router/redirect.tsx and its spec. No redirect in the tree targets
another redirect now, and the helper's own spec passes unkeyed.

Cover the whole table rather than a spec per page: admin/routes.unit.spec.tsx
gains the 14 path/target pairs read off the real route tree, plus the
no-chaining invariant. The four legacy paths that had no e2e case at all
join the redirects spec's table, which now covers all 14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* fix: default the theme editor base path to the hub

The editor's basePath still fell back to /admin/embedding/themes, a
route this branch deletes, while its ThemeListing sibling had already
been moved onto the hub path. Only the hub mounts the editor and it
passes basePath explicitly, so nothing was broken -- but cancel and
delete would have navigated through a redirect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* docs: trim the module boundary comments

The theme-editor note grew from five lines to ten while restating the
move it documents; the settings-components and embedding-hub ones
listed ticket history the code does not need. Keep the reason each
carve-out exists and drop the narrative around it.

Every line touched here is a comment this branch itself added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* refactor: drop the -setup suffix from the wizard paths

get-started/permissions-setup and get-started/sso-setup stutter under a
parent segment that already says setup. Rename them to
get-started/permissions and get-started/sso.

Safe to rename rather than redirect: the hub reaches master only when
embedding-hub-main merges, and that PR is still open, so no released
Metabase has ever served these URLs.

Sharing a final segment with the /embedding/permissions tab is fine --
different parent -- and EmbeddingHubLayout's "does not light up
Permissions on the permissions wizard" test covers exactly that.

The two hub routes come from an earlier PR in this stack; they are
touched here because this branch's redirects point at them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* test: restore the OSS and starter nav checks, fix the upsell

The deleted embedding-admin-settings-{oss,starter} specs each checked
which links their edition's sidebar offered. That sidebar is gone; the
hub shows every tab to every edition and marks the unlicensed ones with
a gem, so assert that instead, once per edition as before.

The OSS upsell test asserted a button and so could never pass. OSS
leaves PLUGIN_ADMIN_SETTINGS.useUpsellFlow returning no trigger, which
sends UpsellCta down its url branch to an ExternalLink; paid builds wire
the trigger up and get an UnstyledButton. Assert the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* test: assert against the hub layout, not admin

The embedding pages now render inside the embedding hub, so the specs
look for embedding-hub-main and the "Embedding hub" nav instead of
admin-layout-content and admin-layout-sidebar.

* test: visit the content localization page

The content translation settings moved off /embedding/security onto
their own /embedding/localization page.

* test: follow the theme editor to its hub route

The editor moved from /admin/embedding/themes/:id to
/embedding/appearance/theme/:id, and it renders full width rather than
inside the admin sidebar layout.

* test: start New embed from the hub nav

The button now lives in the hub nav rather than in the SDK and guest
embed setting cards, so the specs share one clickNewEmbedButton helper.
The guest-only entry point is gone with those cards, and with it the
assertion that it forces guest auth.

* test: drop the modular embedding settings spec

It asserted on the Tenants link inside the settings card, which the
embedding admin settings removal took with it.

* fix: drop the modular embedding gate on version pinning

Version pinning is about the SDK package, so the SDK feature and a
hosted instance are the only conditions it needs.

* docs: say why theme-editor is app tier

Name the import chain that forces it, and why settings-components is
separate from the data-only settings module.

* docs: correct the no-literal-metabase-strings reasons

The strings are exempt because only admins see them, not because they
are legal copy.

* docs: note where the theme upsell coverage went

The component no longer gates itself, so its upsell tests live in
theme-upsell.cy.spec.ts.

* fix: import AdminSettingInput from its relocated path

Master added the fast-mode input to this section after the relocation
commit was written, so the new import still pointed at the old widgets
directory this branch empties.

* fix: redirect the setup guide straight to the hub

/admin/embedding/setup-guide pointed at /embedding, which is itself a
redirect to /embedding/get-started. Every other retired path lands on a
real page, so send this one there directly too.

* refactor: require basePath on the theme editor and listing

Both defaulted basePath for a second host that no longer exists, and the
move to the hub left each default pointing at the other one's path: the
editor would have returned to /embedding/appearance/theme and the listing
would have created themes at /embedding/appearance/new, neither of which
routes anywhere. The hub always passes basePath, so drop the defaults.

* test: assert every hub tab from one configuration

The per-edition nav checks only asserted the four gated tabs, so a gem
appearing on an ungated one went unnoticed. List all seven with their
expected gem state instead, shared by the OSS and Starter blocks.

* test: cut the admin redirect spec to two browser cases

admin/routes.unit.spec.tsx already asserts the whole retired-path table
off the route tree. Keep only what reading the tree cannot show -- that a
redirect fires in a browser and carries its params -- instead of a restore
per path.

* docs: state where the New embed button lives now

The comment contrasted the hub nav against settings cards that no longer
exist, which goes stale as soon as nobody remembers them.

* fix: point the theme editor page at the relocated module

This branch moves the theme editor components from
metabase/admin/embedding/components/ThemeEditor to
metabase/embedding/themes/components/ThemeEditor. The hub's lazy
theme editor page still imported the old path, which the rebase
onto this branch left broken since git has no way to track an
import string across a rename.

Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* fix: drop the stray unused ThemeEditor import in routes.tsx

The ThemeEditor relocation commit's routes.tsx hunk updated a
top-level `import { EmbeddingThemeEditorApp } from ...` line to its
new path. That line had already been deleted when the theme editor
route switched to a `lazy=` loader, so rebasing the relocation
commit onto that fix reintroduced it as an unused import, since git
applied the patch by fuzzy context match rather than conflicting.

Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT26JiAJ25NK11W8PJLymT

* fix: align embedding e2e specs with the hub pages

Repointing these specs from the admin embedding pages to the hub was a
URL and test-id swap, so assertions that the hub renders differently
were left behind:

- The Get started card reads "Configure data permissions and tenants";
  only the permissions page kept "and enable tenants".
- The Security page's upsell passes an onClick, so UpsellCta renders a
  button rather than an anchor and the upgrade URL is not in the DOM.
- Published guest embeds render only when embeds exist, so unpublishing
  removes the section instead of showing empty states.
- The tenants links are gone: tenancy off opens the user-strategy modal
  in place and tenancy on renders a tab to the hub's own tenancy page,
  both covered by EmbeddingHubTenancyPage.unit.spec.tsx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ

* fix: match the hub's checklist card DOM in setup-guide e2e

The migration to the hub kept the admin card's DOM contract. A hub card
is an anchor, a button or a div depending on whether the step links,
opens a modal or is locked, so closest("button") only matched a third of
them; its done badge is a check icon labelled "Step N complete" with no
"Done" text; a locked card carries aria-disabled and a hover tooltip
rather than a lock icon; and the SSO card is titled "Set up SSO".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ

* fix: drop the stale settings wait in content translation e2e

The dictionary upload helper waited on GET /api/setting after visiting
the localization page. The admin page fetched it on load and the hub's
Localization tab does not, so the wait timed out and took the five
upload-form tests with it. The findByTestId assertion that follows
already retries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016amK37aKSDk7sgJsT3AVJQ

* test: assert the OSS upsell renders a link, not a button

The hub's Security page takes its click handler from
PLUGIN_ADMIN_SETTINGS.useUpsellFlow, whose OSS stub returns
triggerUpsellFlow: undefined. UpsellBanner then falls back to
useUpgradeAction, which also returns onClick: undefined off a hosted
instance, so UpsellCta matches its url branch and renders an anchor.

An OSS bundle can therefore never render this CTA as a button. The
assertion passed locally only because build-hot bundles the enterprise
plugins regardless of MB_EDITION; CI's oss-subset job builds the real
OSS bundle and got a link.

Restores the href assertion the migration dropped, with the hub's
campaign and location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

* test: await the throttled recents before asserting the embed

The spec aliases the throttled /api/activity/recents response as
@getRecents but never waits on it. That worked while visitNewEmbedPage
clicked a card on /admin/embedding, which only rendered once that page
had loaded its settings -- several seconds in which the throttled
response made progress.

The hub's "New embed" button is in the nav and is clickable as soon as
the layout mounts, so the wizard now opens immediately and the response
is still in flight when the assertions run. CI shows neither dashboard
title present, not the example dashboard, so this is the wizard still
resolving rather than a fallback.

Awaiting the alias restores what the test name already promises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFXLWvx9oSucyyp5Ezu3Hv

* fix: order the embedding imports as import/order expects

eslint import/order wants metabase/embedding/themes before
metabase/embedding-hub/upsells. Fixes the fe-lint CI failure.

* fix: restore the Box import on the JWT form

The master merge's import resolution dropped Box while the group-schema
block still renders it.

* fix: repoint DomainsSettingsPage at the relocated modules

Master added this page against the pre-move paths for SettingsSection,
SettingHeader and isSettingSetFromEnvVar.

* test: restore the csp-img and custom-viz mock settings

setup() indexes both off generalSettings; master's copy of the mock
dropped them.

* style: order the relocated settings-components imports

import/order after repointing them at the new module.

* fix: drop the settings left behind on the general page

csp-img-enabled and custom-viz-enabled render on the domains page now,
so their reads here were dead, along with the imports they needed.

* style: drop the blank line eslint left in the import block

* refactor: query the hub content by its main landmark

The hub's content area is the main content of the page, so mark it up as
one instead of reaching for it by test id. Drops embedding-hub-main in
favour of component="main" and repoints all 45 call sites.

Kept on the outer Box rather than AreaContent, which the monitor layout
also renders -- tagging it there would give that page a main landmark
too. MonitorContent still uses monitor-main on its equivalent Box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* test: assert the checklist step number explicitly

The label carries the step's position, so matching /^Step \d+ complete$/
proved only that some badge was there. Every one of these sites runs
under a pro token, so the numbers come from the licensed order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* Revert "refactor: query the hub content by its main landmark"

This reverts commit 667b15013d.

* refactor: give settings-components a public API

The module is new in this PR, so every consumer's import line is already
in the diff -- the cheapest moment to add the barrel its shared-tier
neighbours settings and segments already enforce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* refactor: exempt the relocated modules in the eslint config

Leaving admin/ drops these files out of the exemption that turns the
metabase-string and color-literal rules off, and embedding-hub is
already on that list. Two files go back to matching their originals
byte for byte -- an inline comment forced a JSX reflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* fix: validate the setup guide return path

The parameter went unchecked into a link target, so a crafted url
rendered a working off-instance link on an admin page and an empty
one rendered a dead link whose onClick still marked the step done.
Uses the same origin check as the login redirect parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* fix: build the return-to link with URLSearchParams

The old concatenation appended a literal "?" whether or not the step's
own path already carried a query string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* refactor: drop the code the admin removal stranded

The deleted pages were the only callers of UpsellSdkLink, the related
settings helpers and their section wrapper; the theme listing's own
gate was the only caller of UpsellEmbeddingTheme. ColorRow had none.
RelatedSettingCard stays -- the tenants onboarding step uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* refactor: drop showHeading from the theme listing

The admin route that mounted the listing as a whole page is gone, so
its one remaining caller always passes false and the heading branch
never renders. The Appearance page titles the section itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* test: trim dead setup and restore the sdk plugin stub

Three setup parameters and two mock settings had no caller once the
csp-img and custom-viz reads moved to the domains page. The plugin
stub was assigned without ever being put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* test: tighten the hub tab and locked step assertions

assertHubTabs compared one hardcoded absent tab; counting the links
makes the caller's list exhaustive instead. The locked production
step now asserts its tooltip, which replaced the old inline reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* docs: correct three stale comments

The widgets folder is no longer a sibling of AdminSettingInput, the
EMB-2229 sentence promised a tier change that branch does not make,
and the smoketest assertion needed the reason it expects nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

* test: keep the no-fetch assertion for the appearance upsell

The theme listing spec asserted it before its token gate moved to the
page; assert it where the gate lives now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198kPp8K99BRJ15z73m5MY4

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* Move the embedding hub module from app tier to feature tier (#81019)

* refactor: move what the hub shares with admin into common

After the tier flip admin and the embedding hub are both feature tier,
and a feature module may not import another, so anything both read has
to surface into a shared module.

base-path.ts is that: six admin files, three enterprise files and two
hub files read it, so it moves to common along with its spec. The
public-links table moves for the same reason -- admin's Public sharing
page and EmbeddedResources both render it, and EmbeddedResources itself
moves to embedding/settings since only the hub's Security page uses it.

EmbeddingHubPermissionsBasePath hardcodes the hub's path and is read by
the hub alone, so it moves into the hub rather than into common. Every
other file here is a one to three line import repoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XchaaoRd76S9WU3NzhqKW

* refactor: flip the embedding hub to feature tier

The hub's only cross-tier imports are routes.tsx reaching admin's
permissions routes and EmbeddingHubThemeEditorPage mounting the app-tier
theme editor. Both go into an app-tier embedding-hub-routes element
ahead of the feature one, the same carve-out admin-routes used, so the
rest of the module can flip.

Drops admin-routes itself, now dead. #80146 created it solely so
admin/routes.tsx could lazy-mount that same theme editor, replacing a
feature/admin -> app/theme-editor whitelist entry; #81018 then moved the
editor to embedding/themes and left admin with plain redirects, so
nothing in admin/routes.tsx reaches app tier any more. This ticket is
where that edge resurfaces on the hub.

Verified against both rule sets, which are built separately:
module-boundaries stays at 32 problems with no admin/routes finding, and
lint-eslint-pure, which loads enforcedRules, exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XchaaoRd76S9WU3NzhqKW

* docs: drop the stale tenants base path contrast

The comment said this module lives in common, unlike the permissions
editor's equivalent, because it is read from both OSS and EE call sites.
Both halves are now false: this PR moves that equivalent into common
too, and it is read from enterprise as well. The contrast distinguishes
nothing, so it goes rather than getting reworded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XchaaoRd76S9WU3NzhqKW

* fix: keep the theme editor page off the pages barrel

The page sits in the app-tier carve-out so it can import
EmbeddingThemeEditorApp, but pages/index.ts is feature-tier and
re-exported it. The boundary lint does not follow re-exports, so a
feature-tier file could reach the app tier through the barrel: a direct
import errors, the barrel import passed silently.

routes.tsx now imports the file directly, which is legal because
routes.tsx is itself in the carve-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgJHnFajYcKBARP8J33ZmR

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the embedding hub as the seeding trigger

The hook moved behind EmbeddingHubLayout, so it now fires on first visit
to the hub rather than to the themes listing page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV

* refactor: move the embedding hub's Toucan 2 calls into a db namespace

Master added a kondo rule keeping Toucan 2 query calls in module db
namespaces (#82099). The hub's OSS module predates it, so merging master
into the integration branch left 17 warnings in api.clj and a red
Clj-Kondo job.

Extracts the queries verbatim into metabase.embedding-hub.db, mirroring
metabase.embedding-rest.db.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV

* fix: refetch the setup guide checklist after a single setting write

The checklist query is invalidated by updateSettings but not by
updateSetting, so a step written through useAdminSetting -- the AI one --
stayed grey until the page was reloaded.

Tagging the singular mutation rather than the AI page fixes every step
written that way. It costs nothing on instances that never open the hub:
RTK Query only refetches subscribed queries, and updateSetting is already
the pessimistic admin path that refetches session-properties on each call.
The per-click writes go through updateUserSetting, which is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV

* fix: return to the setup guide after creating a theme from it

The editor always went back to the Appearance listing, so the setup guide's
theme step was a one-way trip. It now takes an optional returnPath, and the
hub page sets it from a ?return=get-started marker on the link.

The step links straight into a new theme rather than the listing, so the
return lands where the user actually came from. Only the one known marker
value is honored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzJNGUUd5hZjQjdSjHC6mV

* fix: satisfy the frontend linter on the hub specs

The theme editor's spec imports the page, which is app-tier because it
mounts EmbeddingThemeEditorApp, so the spec is tagged the same way its
routes.unit.spec.tsx sibling already is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reorder the apps in the app switcher

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the tenants docs icon inside the embedding hub

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restyle the New embed nav button

Primary rather than secondary, so it does not get lost on the page. And
collapsed, where it sets the nav rail's width, it takes the same 32px box
as the tabs beside it instead of a taller, wider button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* fix: state the modular embedding terms for guest embeds

Enabling guest embeds writes the same enable-embedding-modular setting
the SSO path does, so a Pro admin ended up with modular embedding on
without ever being shown its terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: turn tenancy on straight from the embedding hub

The button said "Enable multi-tenancy" and then asked which strategy to
use, so the choice was already made twice. It now writes the setting and
the page renders the listing, which is what the hub can do and admin
cannot: admin's own entry point still opens the modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* fix: drop the tenant headings the hub tab already names

Under the hub's Tenancy tab each sub-tab repeated its own name as a page
heading, so the tab bar and the heading said the same word. The listings
take a showTitle prop to leave it out; admin passes nothing and keeps its
headings.

Which host mounted the tenant routes is now one isEmbeddingHubTenancy
helper, mirroring the permissions editor's isEmbeddingHubPermissions,
rather than the base-path comparison the docs button had inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* fix: drop the tenancy invalidation the settings API does

updateSetting already invalidates setup-guide-checklist on every write and
permissions-group when the key is use-tenants, so the hub was asking for
both a second time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* docs: point the tenancy hub check at its permissions twin

Two files now share the name is-embedding-hub.ts, and nothing said the
second was deliberate rather than a duplicate to collapse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* refactor: ask for the tenancy host where the permissions one does

The listings took a showTitle prop fed by the tenant wrappers, while the
permissions editor's components call their own check inline. Follow that,
so the two hub-aware subsystems read the same, and the wrappers go back to
forwarding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* fix: validate the setup-guide return path before navigating

The param is read straight from the URL and handed to navigate, while the
sibling that reads the same param checks it against the site origin first.
Router normalization happens to make an off-origin value land same-origin
today, which is not something to rely on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* style: format the JWT spec after the merge resolution

The merge commit skipped the hooks so master's files would not be
reformatted wholesale, which also skipped the two files resolved by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* test: drop the strategy modal step from the tenancy spec

The hub's Enable multi-tenancy button writes use-tenants directly and
lands on the listing; only admin's own entry point still opens the
strategy modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* test: treat an already-enabled terms card as accepted

A section that mounted on stale settings stays on screen with its
disabled Enabled button, so keying the early return on the card's
presence made the helper look for an accept button that was gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188APErQre5KQuN6WdmJedA

* test: assert the embed preview loader before accepting terms

On Pro, the Guest radio now renders the modular embedding terms card, so
the helper's terms round-trip runs between opening the modal and the
loader assertion. The preview mounts at modal open and its iframe
finishes inside that window, so the loader had already unmounted by the
time the assertion started.

Asserting the loader immediately after the modal opens restores master's
timing while keeping the terms click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: call useUpsellFlow only when the upsell renders

The hook sets window.name in a useEffect, which renames the Cypress AUT
frame. Any page that mounted it broke cy.press even on Pro, where no
upsell is shown -- embed-parameters.cy.spec.ts failed with "Could not
find AUT frame". Both hub pages already gate the banner on the token
feature, so moving the hook into the banner keeps it out of the Pro path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyreUcTwdriuiQ3ngskSpF

* fix: match the designs in the embedding hub layout

Per design review on #80336: the theme editor is full screen again, with
no nav sidebar and no app switcher, and the sidebar's Permissions entry
uses the eye icon the designs call for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyreUcTwdriuiQ3ngskSpF

* fix: keep the secret key input controlled when cleared

Clearing the field saves an empty value, and the backend then reports the
setting as present-but-nil, so /api/session/properties sends null and the
input's value went from a string to null. React flagged the controlled to
uncontrolled switch on every clear.

The two other reads of the setting behave the same either way; they use
the normalized value so nothing in the component sees the raw null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017H2PEghttWPv6wFmLQfEdu

* chore: regenerate modules config after master merge

The merge dropped queries_rest's metabase.embedding.validation require:
this branch removed the check-embedding-enabled gate on the embeddable
cards endpoint, and master removed check-allowed-to-change-embedding.
With no call sites left, queries.rest no longer uses embedding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SnFSpp92VDRya282ZbCWu

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:27:34 +01:00
Arik Aronov
15082c54ff Revert "Resolve snippet template tags subject to read permissions (#78905)" (#82395) 2026-09-13 07:58:12 +03:00
Fraser
99f918928c Move the metabot slash command slot into metabot (#82307) 2026-09-12 16:48:46 +12:00
Kevin Kelleher
c505a9a785 Disable explorations (#82230)
* disable explorations

* fix e2e

* Keep the explorations API tests running while the routes are unmounted

The explorations routes are intentionally left out of the application's
route map, so every `mt/user-http-request` against `/api/exploration/...`
in `metabase.explorations.api-test` and
`metabase-enterprise.sandbox.explorations-test` returned 404.

Let the test HTTP handler be overridden per test: `metabase.server.test-handler`
gains a public `make-test-handler` that takes the API routes to serve, a
bindable `*test-handler*`, and `do-with-api-routes`. A new
`metabase.explorations.test-util` mounts `/api/exploration` in front of the
application's routes (wrapped with `+auth` as the route map used to), and both
namespaces install it as an `:each` fixture.

Also assert that the application itself still 404s on `/api/exploration`, and
regenerate the modules config, which no longer lists
`metabase.explorations.api` as an exported API namespace.

* Keep the explorations Metabot profile unregistered while explorations are disabled

The agent endpoint accepted profile_id "explorations" even though
nothing on the branch mounts the feature, so the profile's tools and
prompt were still reachable over the API. Define the profile without
registering it, and reject an unregistered profile with a 400 before a
turn is persisted or the title job calls the LLM. Tests that exercise
the profile register it through a new test-util helper.

* Close the listing surfaces that still handed out residue exploration rows

Explorations are disabled, but an instance upgraded past this version,
used to create explorations, then downgraded keeps its exploration
tables. Four listing surfaces still handed those rows to clients. Not
anymore!

- Search: "exploration" joins the excluded-models set and leaves the
  models search order, so it drops out of the searchable model set.
  Both engines filter stale index rows at query time, a models filter
  naming it is rejected, and it is no longer an available model.
  Ingestion and the shared API model map are untouched.
- Collection items: dropped from the default model list and the models
  parameter allowlist, so it is neither listed nor requestable.
- Bookmarks: the exploration arm leaves the bookmark union and the type
  leaves the bookmarkable models, so listing omits it and create/delete
  no longer match a route (404, like any unknown model).
- Comments: dropped from the API type map, from which the request
  schema derives, so exploration targets are rejected.

The disabled-state tests now live together in
metabase.explorations.disabled-test: the routes 404 and Metabot profile
400 tests move there from the explorations and metabot API tests, and
one test per closed surface joins them. Tests that asserted explorations
were listed by these surfaces are removed; the comment-context gate test
runs the registered gate directly, since the comments API no longer
serves exploration targets; the exploration model's search test keeps
only its spec-registration check.

Frontend callers that still send the model (the collection content
all-models constant) get a 400 from the items endpoints until that
constant drops it.

* Stop the collection page from requesting the exploration model

* review feedback

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-09-12 01:28:00 +00:00
Braden Shepherdson
79546eb55f UXW-4916: MFA mandatory enrollment flow on login (#79093)
* LDAP and Password auto log out when mfa enabled

* Fix lint

* Update core.clj

* Update gate_test.clj

* Update session_test.clj

* Require MFA enrollment on login when it is `:required` but user is not enrolled

* Tidying up, fixing unit tests

* Update session.clj

* Update session_test.clj

* Associate MFA Auth Identity with session

* Check for present mfa auth

* Uxw 4972 login introduce 2fa enrollment on login fe (#79485)

* updating admin setting

* MFA enrollment on login

* grace period setting

* Recovery Codes

* ..

* .

* ..

* auto grace period, clear button

* starting e2e coverage for enforcement

* Propagate MFA id to new session after getting recovery codes

* SSO mfa e2e tests, FE polish

* Update session.clj

* Update core.clj

* Revert "Update core.clj"

This reverts commit 5474ba3ea4.

* Tests passing aside from my new ones for password reset

* Fix `apply-mfa-gate` logic for SSO logins while MFA is required

* Fix MFA session checks post BE SQLi hardening

One of the `:raw` HoneySQL clauses was missing the `^:allow-raw-sql`
annotation. Such illegal SQL clauses throw 500s in dev mode, but in
prod and test modes they are replaced with `TRUE` or a similar dummy
clause. So the MFA enforcement conditions in the session middleware
were reduced as follows:

- intended: `mfa_auth_identity_id IS NOT NULL OR (auth_identity.provider <> 'password' AND auth_identity.provider <> 'ldap')`
- sanitized: `mfa_auth_identity_id IS NOT NULL OR (TRUE AND TRUE)`

which is equivalent to `TRUE`, nerfing the check completely!

This commit brings back the enforcement.

* more e2e

* comment cleanup

* UX adjustment

* disable required options for non enrolled admins

* confirmation modal

* PR Feedback

* Derive all the new MFA-related audit events from `::audit-log/user-event`

* FE lint and unit tests

* happy e2e

* auth card changes

* Addressing BE review comments; thanks Mike

* BE lint and formatter fixes

* fix up ::provider/supports-mfa usages to use the custom auth-identity hierarchy

* type check fixes

* Keep up with the security hardening: `:closed` input schemas

* Kevin feedback

* Propagate MFA information to the new session after changing your password (#82123)

* Propagate session id into user info

* Update api_test.clj

Add tests

Revert "Update api_test.clj"

This reverts commit 9bd4fce7400ca2fcf9e2760746ea0b6f0c67e65c.

* Fix lints

* Update db.clj

* Update api_test.clj

* Update src/metabase/users_rest/api.clj

Co-authored-by: Braden Shepherdson <braden@metabase.com>

* Rename query

* Update api_test.clj

---------

Co-authored-by: Braden Shepherdson <braden@metabase.com>

* enable the saml container in CI

* typo

* BE unit test fixes

* Add a response schema for our new endpoint `/api/session/mfa/enroll`

There's a ratchet making sure the number of ignores for `defendpoint`s
without response schemas does not increase, so a new endpoint is forced
to include one.

* fix OSS tests

* Revert "fix OSS tests"

This reverts commit 0515db4409.

* Move mfa-enforcement setting into OSS so it functions in OSS tests

It cannot be set to values other than `:off` in OSS, and there are no
MFA providers in OSS either, so it cannot be used.

* fix some dangling references

* green CI

---------

Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
2026-09-11 22:11:01 -03:00
Fraser
75bd580d91 Move the frontend locale context into common/hooks/use-locale (#82000) 2026-09-12 12:26:07 +12:00
Fraser
ca1674fb4e Move the content translation slot into content-translation (#81859) 2026-09-12 12:24:54 +12:00
Andrei Simionescu
4d0744a820 SEC-1218: Only download slackbot files from Slack hosts (#82320)
The bot used to GET whatever url_private a file_share event carried, with the
workspace bot token attached, so a remote file could point it at any address the
registering app chose. It now refuses anything that isn't an https Slack file
host, turns remote files away before download, follows at most one redirect and
only to another Slack file host, and holds the size limit as the bytes arrive
rather than trusting the size on the event.
2026-09-11 22:49:20 +00:00
Jakub Chodorowicz
236a1cc9bd Gdgt 2461 tabs (#82174) 2026-09-11 22:30:22 +00:00
github-automation-metabase
236c7c98f3 Tighten ratchets (#82384)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 21:35:25 +00:00
bryan
34ff3598e3 mcp-v2 integration branch (#81245)
* Enable the feature the unknown-parameter test needs to reach its check

`resolve-parameters` refuses any `parameters` at all without `dashboard-subscription-filters`, and
that refusal runs before the unknown-id check. The teaching-error test supplied a bad id without
enabling the feature, so whenever it ran without ambient token state the call failed on the feature
and never reached the check the test exists to pin — both assertions red, which is what CI hit.

Wrapped in `mt/with-premium-features`, matching the two sibling parameter tests, which already pin
their own token state rather than inheriting it.

Verified fails-when-broken: swapping the feature set to `#{}` fails both assertions.

Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va

* [mcp-v2 12] definitions: cover the write-scope readback degradation

Both tools echo the created/updated row, which carries its name and
definition — so a token holding only `agent:content:write` would learn
the content of anything it may touch. `v2.write/readback` degrades that
echo to an ack, and all four call sites here relied on it untested.

`readback-requires-read-scope-test` drives create and update for both
tools with the write scope alone, asserting the payload is exactly
`{id, note}` and the note names the missing read scope. It then reads the
row from the DB to show the write itself still landed, and repeats the
update with both scopes to show the full row comes back — so the
assertion cannot pass merely because the tool returned something thin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR

* [mcp-v2 12] definitions: reject a blank name

Both tools declare `name` as `[:string {:min 1}]`, which " " satisfies,
so a whitespace-only name was accepted and stored — laxer than the REST
endpoints whose checks these tools otherwise inherit, which take
`ms/NonBlankString`. The JSON schema the MCP manifest is built from
cannot express non-blank, so the check belongs in the handler, beside
the `str/blank?` check `revision_message` already gets.

`check-name!` runs in all four branches immediately after the method-args
check, before any id resolution — so a blank name on update reads as an
argument error rather than "not found".

`blank-name-test` was written first and watched fail: the create half
stored `{"id":1,"name":"   ", ...}`, and the update half returned the
not-found message because the lookup preceded any name check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR

* [mcp-v2 12] definitions: cover clearing a description

`clear` is the only way to unset a property — a null cannot carry that
meaning, since strict clients fill every unset property with null and the
registry strips nulls at the boundary. `dispatch-write`'s expansion is
unit-tested in `write_test`, but nothing checked that a cleared property
actually reaches the column through these two tools.

`clear-description-test` creates each entity with a description, clears
it, and asserts both that the column is null and that the key is absent
from the echo — the concise projection drops nils rather than echoing
null back.

It also pins where a non-clearable property is refused: `clear`'s schema
is `[:sequential [:enum "description"]]`, so that is rejected as an
invalid argument before `expand-clear` runs, making its own "can't be
cleared" message unreachable for these two tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR

* MCP v2 get_parameter_values: reject date constraints chain filtering can't parse

`check-constraints!` promised to reject any constraint chain filtering would
silently drop, but covered only the three field-shaped drop paths. The fourth is
in the value: `chain-filter/add-filter` routes a string value on a temporal field
through `params.dates/date-string->filter` and catches a parse failure into
`nil`, dropping that filter and running the query unnarrowed.

Proven before fixing — a `_DATE_` constraint of "sometime last spring" against a
checkins dashboard was accepted and returned all 100 venues with
`has_more_values false`: a completely unfiltered list handed back as a filtered
one. That is the failure this tool exists to prevent, and a date string an LLM
would plausibly produce is the likeliest way to hit it.

The check runs `date-string->filter` itself, so it can't disagree with
`add-filter` about what parses — the same "probe with the real thing" property
the reachability check gets from `filterable-field-ids`. Temporal-ness is tested
as `effective_type` (falling back to `base_type`) against `:type/Temporal`,
matching `lib.types.isa/temporal?` on the column `instance->metadata` builds. It
is scoped exactly to `add-filter`'s branch — bare strings only, per field-id, so
a multi-field constraint is judged per field the way the fetch judges it. The
error names the grammar that does parse rather than only refusing.

Also fixes an adjacent misdiagnosis in the same fn: when the target parameter has
no field-ids at all, chain filtering falls back to `filter-values-from-field-refs`,
which ignores constraints outright. That was reported as "no join path to this
parameter's table", which sends the reader looking for a missing FK. It now names
the real cause.

parameters-test 32 tests / 89 assertions; :module parameters 151t / 385a;
fix-modules-config unchanged; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJcw6ZamZBNhJ8SQxgJB13

* MCP v2 get_parameter_values: cover constraints and query applied together

The tool's most involved path had no test. `constraints-test` passes constraints
with no query and `query-search-test` passes a query with no constraints, so
nothing reached `chain-filter-search` with a non-empty constraint set — the one
route that also pins `chain-filter/*allow-implicit-uuid-field-remapping*` false.

The test asserts the two narrowings compose rather than merely co-exist: a
category that matches the query but is excluded by the constraint must be absent.
Searching "African" alone finds it; searching "African" under price=4 returns
nothing, which fails if the constraint is dropped once a query is present.

No behaviour change — the path was already correct. This closes the coverage hole
so it stays that way.

parameters-test 33 tests / 92 assertions; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJcw6ZamZBNhJ8SQxgJB13

* MCP v2 get_parameter_values: blame the search when a query matches nothing

An empty result carried one sentence regardless of how it was reached: "its
source may be empty, filtered to nothing for you, or a free-text filter with no
value list." Every cause it offers is a property of the parameter, so after a
search that simply missed, the agent is told the filter may be broken when the
actual recovery is to search for something else — and none of the three listed
causes is even true.

`steering-line` now takes the caller's `query` and, when there was one, names it
and points at the recovery that fits: search for less, or drop `query` to list
everything. The generic sentence still covers the no-query case, where those
three causes really are the candidates.

Threading `query` into `values-content` is the only structural change; the
slicing and the other three branches are untouched.

Covered by no-match-for-query-blames-the-query-test, which asserts on both
targets that the line quotes the search and drops the causes that can't explain
it.

parameters-test 34 tests / 98 assertions; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJcw6ZamZBNhJ8SQxgJB13

* Fix archived document hits being dropped from archived searches

validate-and-enrich-documents re-fetched document hits with a hardcoded
`:archived false`, so any document matched by an `archived: true` search
failed that lookup and was dropped by the `keep` — while `:total` still
counted it. The result was an empty `data` alongside a nonzero total that
no offset could ever reach.

The predicate now takes the archived state the search asked for:
`validate-and-enrich-documents` gains an `archived?` first argument and
selects `:archived (boolean archived?)`, with the call site in `search`
passing `(boolean archived)`.

Reachable only since the v2 search tool started exposing `archived`
through this pipeline; documents are archivable (`:archived true` in the
document search spec), so both `{archived: true, type: ["document"]}` and
an unscoped `{archived: true}` lost every document hit.

Test proven red before the fix (parameter added, hardcoded `false` kept):
archived hit dropped (expected [id], actual ()), and an active hit wrongly
retained for an archived search. The new test pins both directions.

Verified: metabot.tools.search-test + mcp.v2.tools.search-test +
mcp.v2.common-test 72 tests / 270 assertions / 0 failures; kondo 0/0;
cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Test that only a superuser's search reaches the engine for transforms

This pipeline dropped remove-unreadable-transforms and relies instead on
the transform search spec's `:visibility :superuser`, which the engine
applies when it narrows a context to its applicable models. That holds
only if the pipeline hands the engine the caller's real superuser status,
and nothing pinned that.

Captures the context passed to ranked-results and asserts `:models`
contains "transform" for an admin and never for a non-superuser, with
"dashboard" asserted present in both directions so neither can pass on an
empty set.

Proven to be a real guard by mutation: with `:is-superuser?` hardcoded to
true in the per-query context, the non-superuser assertion failed
(expected (not (contains? models "transform")), actual (not (not true)))
while the admin assertions stayed green.

Deliberately narrower than an end-to-end search: getting a transform to
appear in the index needs search.ingestion/*force-sync* plus an explicit
ingest! built from private search.engine/search.ingestion vars (see
metabase.transforms.search-test), which would couple this test to search
internals. An end-to-end attempt was discarded because its positive
control failed — the admin did not find their own transform — leaving the
non-superuser assertions vacuous. The visibility rule itself is covered by
metabase.search.filter-test; what is new here is the wiring.

Verified: metabot.tools.search-test + mcp.v2.tools.search-test +
mcp.v2.common-test 74 tests / 279 assertions / 0 failures; kondo 0/0;
cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Stop a caller-supplied collection-id escaping a metabot's confinement

An embedded metabot, and the nlq profile, may only search inside its own
collection. That is a containment boundary, not a default. The shared
search fn had been changed to let an explicit collection-id take
precedence over it:

    collection-id (or collection-id (when confined? (:collection_id metabot)))

so a caller passing both would search wherever it asked, outside the bot's
collection. Inert today — no caller sets :collection-id, and the v1 tool
schemas are closed — but the v2 search tool fills that key straight from a
request filter, so the escape is one caller away.

Precedence inverted: the confined collection wins, and a caller-supplied
collection-id applies only when the metabot is unconfined.

Test proven red first: confined-collection-is-not-overridable-test failed
on exactly the escape case (expected the bot's collection, got the
caller's) while both controls passed. It pins all three cases — confined
with no collection-id, confined against an explicit one, and an
unconfined metabot still honouring an explicit one, so the fix cannot
over-restrict.

Trade-off: a confined caller asking for a sub-collection inside its
confinement now gets the whole confined subtree instead of the narrower
request. Intersecting would need a descendant check; no caller passes both
today, so it is not built.

Verified: search suites 75 tests / 282 assertions / 0 failures; the other
caller, metabot.agent.core-test, 23 tests / 140 assertions / 0 failures;
kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Build narrowing disclosures from the final type set

When `type` is omitted, each of created_by / collection_id / archived can
narrow the search to the types that support it, and each narrowing is
disclosed. Every disclosure built its "narrowed the search to ..." list as
`(set/difference effective-types (set bad))` — the full engine surface
minus only its OWN exclusions — so with two filters narrowing at once the
later message advertised types the earlier one had already removed.

With {created_by: "me", archived: true} and no type, the archived
disclosure claimed the search covered `collection` and `segment` when the
final type set excluded both. The namespace's stated contract is that
narrowing is disclosed; here it was disclosed inaccurately.

The accumulator now records {:excluded :label :because} per filter instead
of a prebuilt sentence, and the sentences are formatted after the fold
from the final narrowed set — one correct type list everywhere, each
message keeping its own reason for what it excluded.

Test proven red first: the archived disclosure failed with
  expected: "action, dashboard, document, measure, metric, model, question"
  actual:   "action, collection, dashboard, ... question, segment"
The created_by disclosure passed, because that filter's exclusions are a
superset and its private subtraction coincided with the final set — which
is why no single-filter test could have caught this.

Single-filter wording is unchanged, so the existing
omitted-type-narrows-and-discloses-filter-incompatibility-test passes as
written.

`:label` rather than `:filter` for the key: as `:filter` it shadowed
clojure.core/filter in the formatting fn. Kondo did not flag it.

Verified: mcp.v2.tools.search-test + mcp.v2.common-test +
metabot.tools.search-test 76 tests / 288 assertions / 0 failures;
kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Steer an empty list page instead of returning a bare envelope

truncation-line only fires on arithmetic truncation — (offset + limit) <
total — so a page that came back empty carried no steering line at all.
An offset at or past the end produced

    {"data":[],"returned":0,"total":37}

which reads as "nothing matches" when the truth is "nothing *here*", and
gave the caller no hint that paging back would help. The same silence hit
an empty first page whose every match was dropped after the count (a stale
index hit, a row gone unreadable).

list-content now emits an empty-page line whenever data is empty and total
is positive, splitting the two cases: a positive offset is told the offset
and total and steered back with a smaller `offset`; offset 0 is told the
matches are no longer readable or have been removed, and is NOT steered to
an offset, which could not help. A floored total stays "at least N".

Deliberately silent when total is 0 or unknown — that envelope already
says what happened, and inventing a line there would be noise.

truncation-line itself is untouched, so the parameters tool's own
steering-line wrapper (which handles its zero-returned case before ever
reaching truncation-line) is unaffected.

Test proven red first: the five positive assertions failed against the
bare JSON payload, while all three negative controls (total 0, unknown
total, non-empty page) passed before and after.

Verified: common + v2 search + v2 parameters + metabot search 107 tests /
377 assertions / 0 failures; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Disclose the types a collection-scoped search silently drops

The namespace's contract is that the engine's silent narrowing is never
relied on. Two types slipped through it when `type` was omitted and
`collection_id` was set:

  transform — its spec has `:collection-id false`, so
    search-context->applicable-models drops the model outright from any
    collection-scoped search. Always, on every edition.
  table — the ::collection-hierarchy where-clause excludes tables when the
    Library feature is absent, so OSS silently searched no tables.

Named explicitly, each was already a teaching error. With `type` omitted
both were dropped in silence, and the disclosure the caller did get named
them as in scope: "collection_id narrowed the search to ..., table,
transform — database, measure, segment don't live in collections."

Both are now narrowed and disclosed alongside the collectionless types,
each with its own reason, since they are excluded by different parts of
the engine and only one is edition-dependent.

The transform half was not in the original review note; it surfaced from
the pre-fix disclosure text while fixing the table half.

Tests proven red first: 6 assertions across the two affected deftests.
One pre-existing assertion legitimately changed — the collection_id
sub-test of omitted-type-narrows-and-discloses-filter-incompatibility-test
asserted exactly 1 disclosure and now sees 2. It is also now pinned to
#{:library}, so the count no longer depends on whichever token the suite
happens to run under.

Verified: v2 search + common + parameters + metabot search 108 tests /
386 assertions / 0 failures; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Sign the MCP session capability payload so a client cannot self-assert it

`supports-mcp-ui?` is what `:required-extensions` gates on, and it read a
payload the CLIENT echoes back in its own session id. Unsigned, that is a
self-assertion: any caller could mint `<uuid>.{"v":1,"ui":true}` and claim
MCP Apps support it never advertised at `initialize` — which is what let a
narrow bearer reach `refresh_ui_credential` and mint a UI credential.

Session ids are now `<uuid>.<payload>.<signature>`. The signature covers
the uuid as well as the payload: every UI-capable session carries the
identical payload, so a payload-only signature would be a reusable token
that could be lifted onto any correlator. It is domain-separated from the
UI-credential HMAC so neither can be replayed as the other.

An unsigned or tampered id remains a VALID session. The id authenticates
nothing — it is a stateless correlator, and any well-formed one works for
its presenter, which `well-formed-but-never-issued-session-id-is-accepted-for-its-presenter-test`
pins. Only the claim inside it is disbelieved, so the gate fails closed
without breaking that property.

`supports-mcp-ui?` also stops returning true for a bare UUID. That branch
made the signature pointless — a caller could omit the payload entirely
and be believed anyway. Such ids predate capability hints, so `unknown`
is the honest reading.

Behavioural, and shared with v1: a client holding a session minted before
this change loses MCP Apps tools until it re-initializes. Bounded by
session lifetime, and it fails closed.

* fix(mcp): ignore database id for get_fields

Allow agents to carry database_id through the browse hierarchy without rejecting get_fields requests.\n\nRefs GHY-4396

* Stop stamping the MCP UI credential as an unrestricted session

The credential was stamped `::scope/unrestricted`, so the route allowlist
was the only thing between it and full session privilege. Those are two
different questions — which routes the credential may pass through, and
what privilege it arrives with — and collapsing them into one value meant
any way of reaching a route off the list (a routing change, a new alias,
a mistake in the set) served the request as the user.

It now carries `::scope/mcp-ui`, a second sentinel alongside
`::unrestricted`. It satisfies no endpoint's declared `:scope`, and
`ensure-scopes-checked` refuses it where none is declared, so the same
mistake fails closed. Being a keyword it can never be requested, granted,
or named on a consent screen. `:token-scopes-checked` is what lets the
allowlisted routes serve it, since they declare no scope of their own.

Deliberately NOT done by annotating those routes with the sentinel:
`/api/user/current`, `/api/session/properties` and the `/api/dataset`
routes live in `users-rest`, `session` and `query-processor`, and marking
them would push MCP vocabulary into three unrelated namespaces —
deepening the coupling GHY-4400 exists to remove, to buy locality in a
set currently frozen at eight entries.

The allowlist's docstring now says what it is: an inventory of what the
embedded app happens to call, because the credential is installed as
app-wide auth on the client, rather than a decision about what it should
reach.

* [mcp-v2 12] definitions: pin the table-move test's permission precondition

`table-move-permission-test` passed a solo namespace run but failed CI's
full-suite partition. `with-no-data-perms-for-all-users!` resets only the
All Users group, so the temp group's access to `checkins` was left at
whatever default a freshly created group inherits — ambient state that
differs between a namespace run and a full partition. When the analyst
could author on `checkins`, the retarget succeeded and the assertion
failed as "expected a tool error, got success".

Block the database for the group and grant `venues` back, so `checkins`
is denied by construction rather than by the absence of a grant, and
assert both halves of the precondition up front so a recurrence fails at
the setup with a clear message instead of as a confusing success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR

* Stop answering JSON-RPC notifications

A message carrying no `id` is a notification (JSON-RPC 2.0 §4.1) and must
not be answered. A known method sent without one — `{"jsonrpc": "2.0",
"method": "ping"}` — executed and then replied with `"id": null`: a
response invented for something the client is not listening on, in the
shape §5 reserves for a message that could not be parsed at all.

Notifications still execute. A notification is a request the client is
not waiting for, not one to ignore, so the dispatch is unchanged and only
the result is dropped. The malformed case keeps its null-id reply, which
is what §5 actually asks for; conflating the two is what produced the
bug.

Keyed on absence of the `id` key rather than a nil value, so a request
that explicitly sends `"id": null` is still treated as malformed rather
than silently swallowed.

* Pin the superuser flag instead of reading it off a shared test user

transform-visibility-is-superuser-only-test failed on the MySQL 9 EE and
MariaDB EE app-db shards, deterministically on both. The failure was the
POSITIVE control: the admin case saw models #{"dashboard"}, so transform
was absent for everyone and the non-superuser assertions were proving
nothing.

Cause: the test took the superuser flag from the ambient test user, and
`mt/with-test-user` resolves it through `request/with-current-user`, which
SELECTs the shared user row:

    (t2/select-one [:model/User [:is_superuser :is-superuser?] ...] :id ...)

That makes the assertion depend on the state of a row other tests in the
same parallel shard also touch. This branch has already been bitten by the
same class of pollution once — recents-total-is-a-floor-test, fixed by
moving off the shared :crowberto user.

`api/*is-superuser?*` is now bound explicitly. That is the exact var
`search.filter/visible-to?` reads, and pinning it is what the assertion is
actually about: that this pipeline propagates the caller's superuser
status into the search context, not where the status came from. A sanity
assertion on the admin branch ("dashboard" must be present) keeps a future
empty/short model set from passing as a green positive control.

This isolates the variable rather than hiding it: if transform were being
dropped for some reason other than the superuser flag, the test would
still fail on those shards with the flag pinned true.

Still catches the regression it guards: with `:is-superuser?` hardcoded to
true in the per-query context, the non-superuser assertion fails with
expected (not (contains? models "transform")), actual (not (not true)).

Verified: metabot search + v2 search + v2 common 79 tests / 308 assertions
/ 0 failures; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Run the transform visibility test on both search engines

The previous fix was wrong. transform-visibility-is-superuser-only-test
kept failing on the MySQL 9 EE and MariaDB EE shards after it, with the
positive control still red:

    "a superuser's search reaches the engine with transforms in scope"
    expected: (contains? models "transform")
      actual: (not (contains? #{"dashboard"} "transform"))

The superuser flag was never the cause. Pinning it did isolate the
variable, which is how this became findable: with `:is-superuser?` bound
true the admin case still saw no transform, so the gate had to be
elsewhere.

Two engines gate transforms by different code. MySQL and MariaDB cannot
hold the appdb search index, so they fall back to in-place, whose
applicable-models has a rule the appdb path has no equivalent of:

    (empty? enabled-types) (disj "transform")

`enabled-transform-source-types` comes from `transforms-enabled`, whose
getter falls back to a cloud-token check when the setting is unset — so
under `with-premium-features` it was false, the source-type set was empty,
and in-place dropped transform for everyone. H2 uses the appdb path, which
has no such gate, so the test passed locally and was meaningless on the
two shards that ran the other engine.

Both inputs are now pinned: `transforms-enabled` is set explicitly, and
the body runs under `with-appdb-search-and-legacy-search` so the shard's
engine choice can no longer decide whether this test means anything. The
assertion count doubling from 4 to 8 confirms both passes execute.

Diagnosis proven locally rather than by another CI round-trip: with
`transforms-enabled` flipped to false, exactly 1 of 8 assertions fails —
the superuser case, on the in-place pass only — reproducing the CI failure
byte for byte on H2.

Verified: metabot search + v2 search + v2 common 79 tests / 312 assertions
/ 0 failures; kondo 0/0; cljfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* fix(mcp): include dashboard parameter wiring

* Cover the query_handle source, which no live test reached

Both handle tests were deferred behind the unregistered execute_sql tool, so
nothing exercised resolve-source's query_handle branch: a regression there —
notably resolving through the native-rejecting resolve-query-handle! instead of
resolve-query-handle-for-save!, which would make every execute_sql handle
unsaveable — passed the suite untouched.

Mint handles straight into the store instead, the way question_test already
does. The deferred tests stay as the ledger for the whole-tool path; these pin
the branch in the meantime.

Four cases: an MBQL handle creates a transform whose target follows the
handle's database, a native handle stores as a native transform without
re-demanding agent:sql:run (minting it already passed that gate — the
exemption is deliberate, and is the half of the contract the mutation above
would silently break), a handle swaps an existing transform's source, and an
unknown handle teaches the recovery rather than surfacing an internal error.

Verified by mutation: pointing the branch at resolve-query-handle! fails 2 of
the 4; disabling the branch outright fails all 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc

* Mint a cursor only for a projection with a provable unique key

`tiebreaker-specs` fell back to the full projected tuple whenever no source-table
PK was projected, and treated that tuple as a total order. For an unaggregated
stage it isn't: the tuple repeats, and the strictly-past-the-boundary keyset then
skips every row tied with the boundary.

A `fields`-narrowed projection of non-unique columns hit this. PRODUCTS holds 200
rows across 4 categories; paging a CATEGORY-only projection at row_limit 5 served
20 rows and then reported itself complete — no truncation flag, no cursor, 180
rows silently gone.

Split the two meanings the function conflated. `unique-key-specs` now returns nil
when neither uniqueness proof holds, so a missing proof is distinguishable from an
order that is already total, and both callers refuse instead of inventing one.

The second proof has to look back past the last stage rather than just testing it:
`next-page-query` carries an aggregated query's keyset in an appended stage, so
from page 2 on the last stage is that pass-through — unaggregated and PK-less.
Inspecting only the last stage would close the gap and kill every aggregated chain
at page 2 instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS

* fix(mcp): address v2 protocol review

Reject unsupported DELETE without dropping session ownership. Enforce JSON-RPC 2.0 envelopes and notification silence. Keep pre-dispatch tool failures on the JSON-RPC error channel.

* Carry the target's database through a source-database swap

The target was only recomputed when the call named one, so an update that
swapped the source onto another database left the stored target pointing at the
old one. The target records the database it writes and that database follows the
query, so the row — and the echo built from it — named a database the transform
does not write. Feeding that echo back then hit resolve-target's target/query
database check and was refused, dead-ending the read-modify-write the tool is
built around. Only the JSON blob was stale; before-update recomputes the
target_db_id column, so runs went to the right place.

Patch the database onto the existing target instead of rebuilding it through
resolve-target: a target this tool can't author (incremental) still gets its
database corrected rather than becoming uneditable by a source-only edit. The
caller's own `target` still wins, being later in the cond->.

The test drives it across a real second database via with-temp-copy-of-db and
was watched failing first: stale database in both echo and stored row, plus the
round-trip rejection as the third symptom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc

* Supersede the previous page's keyset instead of stacking beside it

`lib/filter` appends — `add-filter-to-stage` skips only exact duplicates — so
every cursor page ANDed a fresh boundary predicate onto the stage while leaving
its predecessors in place. Paging ORDERS two rows at a time, the stored handle
grew 668 -> 2692 bytes over nine pages and kept going, one dead predicate per
page, in the payload and in the emitted WHERE alike. With the full-tuple key each
of those clauses is quadratic in the projection width.

Stamp each minted clause and drop stamped ones off the target stage before adding
the current page's. Dropping them cannot change results: every row the new
predicate admits lies strictly past a boundary row that itself satisfied the old
one, so the new predicate implies the old. Filters the caller wrote carry no stamp
and are left alone.

The stamp rides in the clause's options map, which no schema declares. It survives
serialization, the base64/JSON round trip through the handle store, and
rehydration today; should Lib ever strip undeclared option keys the predicates
would accumulate again exactly as before — same results, larger query — so this is
an optimization and not something correctness rests on.

Stored handle now flat at 716 bytes across nine pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS

* Finish the sentence that teaches the source shapes

`accepted-shapes` is appended to five source-shape teaching errors — it is what
an agent reads to work out how to retry — and it promised "either" and then
listed one dialect. The missing half was the name-based dialect, which
definition->query genuinely resolves via portable-query?, so the message
advertised only one of the two shapes the tool accepts. Restored in the wording
metric.clj already uses for the same pair.

Nothing held the sentence to the code, because only the numeric-id dialect had a
test. Cover the portable branch, and assert the sentence names both dialects so
the half-written form can't come back.

Verified by mutation: disabling the portable branch fails the new
portable-definition test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc

* Refuse tag ids that name no tag, instead of dropping them

update-transform-tags! filters unknown ids out of the list it is given and
writes what's left, so `tag_ids` — documented as "replaces the current list" —
replaced it with a shorter one and reported success. An agent constructing ids
from a stale read has no reason to look twice, and jobs select the transforms
they run by tag, so the tag that failed to attach is noticed when a schedule
doesn't fire. Checked in the tool rather than in the shared write, which REST
also calls: this is the same restatement check-target-free! makes of REST's
target-conflict 403.

Cover collection_id and tag_ids while here — neither had a test, which is why
the silent drop had gone unremarked. The transform-folder namespace rule is
pinned too, since that refusal comes from a layer below this tool.

Adds a :model/TransformTag reference, so the generated module config gains it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Lzc5ERUWqWRx9JbDxK5Lc

* Pin the cursor's reliance on the QP to re-type a stringified boundary

Every execute_query run carries `js-int-to-string?`, which renders integers past
2^53-1 as strings. The cursor boundary is read straight out of the served row, so
a key column holding such values mints a predicate that compares a numeric column
against a string literal — verified, it really does emit
`[">" [field type/BigInteger] "9007199254740995"]`.

That compiles correctly, on every driver, because the query processor re-types it:
`wrap-value-literals` gives the bare literal the column's effective type and
`auto-parse-filter-values` parses it back to a bigint, both above the driver layer
(preprocess.clj:106-107).

Nothing covered that, and it is invisible from the cursor code, which reads a row
value and hands it to `lib/>` with no indication that its type is the query
processor's problem. Add a fixture whose values straddle the boundary and a test
that pages across it, plus a comment naming the dependency, so a change to the
middleware order or to the boundary read fails loudly here rather than in a
warehouse.

No production behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS

* Mint a handle from the question, not from the page it was read on

A cursor page's stored query embeds the keyset boundary it resumed from — that is
how paging works — and the handle returned alongside was minted straight from it.
So a caller who paged to page 3 and then saved got a card reading "orders WHERE
id > 4171": a scroll position persisted as a question, which is nobody's question.

Strip the minted keyset off the last stage before storing, in `mint-handle!` so
every mint site is covered. Only predicates this namespace stamped come off; a
filter the caller wrote is part of the question and stays. Paging is untouched —
the cursor carries its own boundary — and it is a no-op for a query that never
paged.

Re-running a cursor page's handle now serves the question from the top rather than
that page again. The tool description and the namespace docstring promised
"exactly the query that ran" and are corrected: a handle is what a later tool
saves or visualizes, so the page boundary was never the useful part of it.

One artifact stays: a query carrying its own :limit has it spent down across pages,
so a deep page's handle asks for the rows still owed rather than the count the
caller wrote. Recovering that needs state the handle store deliberately does not
keep; `without-page-boundary` says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS

* Register the :document projection where both its tools can see it

document.clj projects through :document but did not require the namespace
that registered it, so the registration was reachable only via api.clj's
alphabetical require order. Loading the document tool on its own — from a
test, or any future consumer — left the projection unregistered, and
projections/project throws a 500 that the error sanitizer collapses to a
bare "Internal error".

Move the registration to projections.clj, which both tools already require,
matching the :question block's rule for a projection two tools share. The
key vector goes with it; content.clj's select-keys pre-narrow is dropped
rather than duplicated, since project re-applies select-keys on every path
and ::document retains the full map anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Return dimensions the way the metric and measure endpoints do

The `dimensions` include documents itself as returning the same
dimensions/dimension_mappings pair GET /api/metric/:id and
GET /api/measure/:id return. Nothing compared the two, and it drifted three
ways:

- It skipped the wire encoding, so the section shipped the internal
  kebab-case shape (display-name, effective-type, and an internal
  :lib/source) where every other field in the tool, and the REST API, uses
  snake_case REST property names.
- It kept orphaned dimensions, which both endpoints drop unless asked for
  them. An agent grouping by one would query a column that no longer exists.
- compute-dimensions reconciled every computed pair for an uninitialized
  metric, while seeding takes own-table and explicitly-joined columns only.
  A metric nobody has opened in the UI reported every FK-reachable column,
  and since this read never persists, it kept reporting them.

Encode through ->api-dimensions/->api-dimension-mappings, drop orphans, and
give compute-dimensions the same three-way metric branch sync-dimensions!
has, sharing one definition of the seed filter with it.

The parity test this needed also replaces the curated-metric fixture, which
was built from get_content's own output and so agreed with whatever shape
the tool emitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Don't record a document view on a get_content read

`documents/get-document` publishes `:event/document-read` unless told not
to, and that handler bumps view_count, stamps last_viewed_at, writes a
view_log row, and pushes the document onto the caller's recently-viewed
list. get_content declares readOnlyHint and idempotentHint, and the
dimensions include already goes out of its way to compute without
persisting; a document read was quietly doing four writes and filling a
user's recents with the agent's reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Register the :metric projection once, where both its tools can see it

content.clj and metric.clj each registered :metric with identical key
vectors. register-projection! overwrites silently, so which one won was
decided by api.clj's require order, and the two would have drifted apart the
first time either was edited.

Deleting either copy on its own just moves the problem: whichever tool keeps
the registration leaves the other reading a projection registered by a
namespace it does not require. Register it in projections.clj instead, which
both tools already require — the same place :question and :document live,
and for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Drop the TODO comments above the two restored document tests

Both tests were deferred while get_content did not exist and came back with
this slice; only the notes telling a reader they were still disabled were
left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Stop documenting a per-type scope gate that isn't there

The type->spec docstring said each entry carried "the extra runtime :scope
the type needs on top of the tool's base agent:resource:read", and the ns
docstring said each item was scope-checked. No entry has a :scope key, the
handler never reads one, and agent:resource:read is not a scope that exists.

The behaviour is right — GHY-4225 folded the per-entity read scopes into
agent:content:read, and the tool gates on that once at the registry
boundary, the same way search does. Only the comments were wrong, and a
comment describing a security control that isn't implemented is one a reader
will trust. Say what actually happens, and say that the absence of a
per-type scope is deliberate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Cover the read paths get_content had no tests for

Four gaps, all coverage rather than defects — the behaviour was already
correct in each case:

- entity_id resolution had no test at all, though it is half the id schema.
  Covers collection, question, snippet and document by entity_id, plus the
  two teaching errors: alerts are numeric-only, and a string that is neither
  shape is rejected.
- the definition include was only exercised for question. measure, segment
  and transform each build theirs behind a catch that returns nil, and the
  section is omitted when it does, so a broken exporter was indistinguishable
  from a type with nothing to export.
- reads-formerly-gated-types named transforms but tested only alert, snippet
  and document, so no transform read ran under a real agent:content:read
  token.
- the transform target table had no test.

On that last one: the permission gate's false branch turns out to be
unreachable. Reading a transform requires superuser or data analyst,
superusers read every table, and table-permission-for-user hands every data
analyst manage-table-metadata :yes unconditionally, which alone satisfies
can-read? on a Table. Rather than force it with redefs, the test covers the
reachable paths — present on a detailed read, absent on a concise one, absent
when the target has never been created — and records why the gate is dead
code kept as defence in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Say what the SQL kill switch actually gates, and stop telling callers to pass a cursor alone

Two docs that contradicted the code.

check-execute-sql-enabled! claimed "every v2 path that runs or stores raw SQL
consults this one gate." It does not: run_saved_question executes a stored native
card without it, as does execute_query over a :source-card naming one. The gate
covers the paths where the agent AUTHORS the SQL — execute_sql, and question_write
/ transform_write storing agent-authored native — which is what the setting's own
description scopes it to, and what the card's read check guards on the other side.
Say that instead, so nobody infers the stronger rule and treats those two paths as
holes.

The cursor argument told callers to "pass it alone" while the tool description
told them row_limit alongside keeps the page size — and the cursor test passes
both. The exclusivity it was reaching for is already stated in the same sentence
and covers only query | query_handle | cursor, so an agent obeying the arg would
have silently dropped back to 100-row pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL8iPKC7nSLarfcnYDkaCS

* Move the v2 redaction queries into mcp/db.clj, guarding the id splice

`redaction.clj` was the one namespace this branch adds that ran its own
app-DB queries, against the policy that a module's queries live in its
`db.clj`. Both move across unchanged in behaviour: the `t2/hydrate` call
becomes `mcp.db/hydrate-notification`, and the batched tenant lookup
becomes `mcp.db/user-id->tenant-id`. `lookup-recipient-tenant-ids` keeps
only the part that is actually redaction logic — deriving the ids of the
recipients whose `:user` hydration came back nil.

The empty-collection case moves with the query rather than staying at the
call site: `[:in ()]` is not valid SQL, and that is a fact about the query,
not about recipients.

`user-id->tenant-id` refuses anything that is not a collection of
integers. The ids are spliced into an `[:in ...]` clause, where HoneySQL
binds a number as a parameter but renders a string, a raw form, or a
nested map as SQL — so a non-integer element arriving there is an
injection vector rather than a type error. It is refused before any SQL is
built, and the offending values ride `ex-data` rather than the message so
nothing caller-controlled reaches a log line.

The check is `int?`, not `Long`: `core_user.id` is a 32-bit column and
comes back from JDBC as an `Integer`, so a Long-only check would satisfy
any test written with literals and then refuse every real call. `int?`
still admits only boxed integer types, which is the whole of what the
guard needs. It throws rather than using `assert`, matching the mint-time
guard in `session.clj` — `*assert*` can be compiled out, and a guard that
disappears in some builds is not a guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FwF5gxSyq2T5FsnyhViozq

* update tools descriptions

* update mcp v2 skills (#81915)

update skills

* Name the right threat in the user-id splice guard

The guard's docstring said HoneySQL renders a string in an `[:in ...]`
clause as SQL. It does not — strings and numbers are both bound as
parameters. The shapes HoneySQL reads as syntax rather than as a value are
vectors (function calls, so `[:raw "1) OR 1=1 --"]` renders verbatim and
`[:inline x]` inlines a literal), maps (subqueries), and keywords and
symbols (identifiers). Those are the injection vectors; a string id is a
plain bug, refused all the same because a silent no-match is a worse answer
than a throw.

The check itself was already correct and is unchanged. Only the reason
given for it was wrong, which matters for a guard people will edit code
against — the docstring is what tells the next reader which shapes have to
stay refused.

Covers `[:inline ...]` and a bare symbol in the refusal test, the two real
shapes the first pass missed.

Deliberately not wrapping the ids in `[:lift ...]` on top of this. It is
unreachable behind an `int?` check, and it would trade the throw for
driver-dependent silence: `[:in [[:lift "1"]]]` binds the string and H2
coerces it, returning a match, where Postgres would not. For this lookup
that is the wrong direction — a recipient whose tenant silently reads as
nil slips past the tenant filter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FwF5gxSyq2T5FsnyhViozq

* mcp-v2.02 bookmark_content (#81246)

* MCP v2 tool: bookmark_content

Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).

Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.

Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.

Tests: 9 tool tests + bookmarks REST suite green.

Review question for this PR: one tool — is bookmark_content's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
  pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).

CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).

Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.

--- Round-2 /review-pr response ---

IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).

MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).

MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.

Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.

* Route resolve.clj through mcp.db; correct the db.clj docstring

Three fixes from review of the mcpv2-01-core merge resolution.

resolve.clj called t2/select-one directly, so the toucan2 kondo rule this
same change adds shipped already violated by a file it introduces -- and at
the highest-value site, where an agent-supplied id enters a query. Adds
mcp.db/select-one-by-id, which asserts the id is already an integer (entity
ids are resolved before they reach it, so a string there is a bug rather
than a lookup), and routes resolve.clj through it. The rule now flags only
v2/redaction.clj, which predates this branch.

bookmarks/db.clj's ns docstring still claimed every function was "a direct
Toucan 2 call with no additional logic" after the generic dispatch tier
moved in, which is the one part of that docstring a reader relies on.

insert-bookmark!'s unique-constraint rationale moves from its docstring to
an inline comment on the select-or-insert! call it explains.

Tests: bookmarks.api-test + mcp.v2.tools.bookmark-test + mcp.v2.resolve-test,
30 tests / 150 assertions, green.

* readback: refuse an empty read-scopes instead of skipping the gate

`(remove f [])` and `(remove f nil)` both yield `()`, so an empty or nil
`read-scopes` made `missing` empty and returned the full ungated row -- a
gate that did not run was indistinguishable from one that ran and passed.

Not reachable from `bookmark_content`, which passes a literal one-element
vector. It matters because this namespace is shared write-path machinery for
the `_write` tools still to land, and the mistakes a future caller actually
makes -- omitting the argument, or computing `read-scopes` from a per-type
map that misses -- both landed on silently ungated rather than an error.
There is no legitimate empty case, so it throws.

Adds write_test.clj, which the namespace did not have: full-row grant,
degradation (exact key set, so `:name` cannot leak), empty and nil
`read-scopes` refusal, the unrestricted sentinel and nil `token-scopes`,
note pluralization, and an ack-key absent from the row. Verified the
refusal test bites -- removing the guard fails it twice (`[]` and nil) and
leaves the other five green.

Also drops `readback`'s 3-arity, which had no callers, and the ns
docstring's reference to which slice lands next.

Tests: write-test + tools.bookmark-test + resolve-test + bookmarks.api-test,
36 tests / 161 assertions, green.

* Clean up comments in resolve.clj

Removed unnecessary comments for clarity.

* Drop the stray toucan-in-mcp kondo rule from this branch

The rule entered here through the mcpv2-01-core merge resolution rather than
from either parent, and it now lives on mcpv2-00b-shared-infra next to the
mcp/db.clj boundary it governs. Keeping a second copy here only produced seven
false positives on MCP test namespaces, whose toucan2.core requires are fixture
setup rather than the agent-id boundary the rule protects.

Every src/metabase/mcp namespace that touches toucan2.core is already exempt
(db.clj and models.*), so the rule guards nothing on this branch. It comes back
with the shared infra when the stack restacks.

* Fold the dead per-model inserts into one unknown-model failure

The generic (model, id, user-id) tier left three sibling functions dispatching
three different ways. insert-bookmark! reaches the bookmark models through a map
and select-or-insert!, so the five per-model insert fns it replaced had no
callers left anywhere; delete them.

An unrecognized model string failed differently in each of the three: a case
with no default threw "No matching clause", while insert-bookmark! destructured
a missing key into [nil nil] and failed further down inside Toucan with a nil
modelable. Route all three through one ex-info. Nothing reaches this today --
both callers validate against a schema first -- but this branch promotes
metabase.bookmarks.db to module API, so callers that validate nothing else can
now arrive.

Also guard the REST contract the insert swap sits under: a duplicate POST to
/api/bookmark/:model/:id still answers 400, which nothing covered before.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>

* mcp-v2.00b: land common.clj at its final size in 01 (#81975)

* Extract the v2 shared machinery into 01

The decomposition rule was "shared machinery lands with its first consumer."
The cost is that the first consumer becomes a dependency for every slice
after it, so a slice needing two functions inherits everything beneath the
slice that happened to add them.

`get_content` is the worked example: it calls `common/response-format` and
`common/select-fields`, both of which first appear in 08-search, so it sits
behind eight slices to reach two functions. Slices 03, 06 and 07 contribute
nothing it uses -- 03 adds zero defs to common.clj, 07 zero, 06 one. They
are in its path because 08 was stacked on them chronologically.

Brings the three shared namespaces to their final size here instead:

- common.clj 172 -> 342: the fields/response-format machinery (with its
  private helpers) and the list envelope. `select-fields` needs
  `projections/catalog`, which resolves from a runtime registry and whose
  namespace already ships in 01; `LevenshteinDistance` is already on the
  classpath via metabot.
- resolve.clj (118) and write.clj (85), which 02 created and 03 finalised.
  Taken at their reviewed state, including routing the read through
  `mcp.db/select-one-by-id` and refusing an empty `read-scopes` in
  `readback` rather than silently skipping the gate.
- mcp/db.clj gains `select-one-by-id`, which resolve.clj needs.

Module config regenerated: `channel` (list envelope urls) and
`eid-translation` (id resolution) join the mcp module's :uses.

Slices 02, 03, 06, 07 and 08 still define this code and will conflict with
identical content when they merge; each needs the duplicate dropped. In
exchange 16 of the 21 slices stop depending on each other and can be
reviewed in any order.

Tests: v2 common/registry/api/redaction + transport, 75 tests /
445 assertions, green.

* Move the toucan-in-mcp kondo rule here, with the code it governs

The rule discourages toucan2.core outside metabase.mcp.db, and this branch is
what introduces resolve.clj routing its read through mcp.db/select-one-by-id.
Landing the rule with the slice that first followed it left a window where the
convention held in code but nothing enforced it.

Exemptions unchanged: metabase.mcp.db is the seam itself, and
metabase.mcp.models.* define the models it sits on top of. Warning rather than
error while v2/redaction.clj still violates it.

* Bring resolve_test and write_test along with their namespaces

This branch was shipping resolve.clj and write.clj with no tests -- including
readback's empty-read-scopes guard, which exists precisely because that
function had none and its fail-open path went unnoticed.

Tests: resolve-test + write-test + common-test, 17 tests / 51 assertions.

* Scope the toucan rule to source, and land the redaction fix with it

CI failed on six warnings, all in test namespaces. The pattern matched
namespace names beginning `metabase.mcp.`, which includes
`metabase.mcp.api-test` and its siblings. Tests call Toucan directly on
purpose -- fixtures, row assertions, counts -- and none of that is an
agent-supplied trust boundary, so the rule's reason for existing does not
apply to them. Excluded using the same `-test`/`test-util` convention the
file's own `test-namespaces` group uses rather than inventing another.

That left one real violation, v2/redaction.clj, and the fix for it already
existed on 02 (8791186195: the redaction queries moved into mcp/db.clj
behind an id-splice guard, with db_test.clj). Cherry-picked here, since a
rule that arrives already-violated teaches the next reader that violating it
is normal.

clj-kondo over src/metabase/mcp and test/metabase/mcp: 0 errors, 0 warnings.
Tests: db-test + v2 redaction/resolve/write/common, 25 tests / 82 assertions.

* Drop the toucan-in-mcp kondo rule; it belongs in the closing PR

Landing the rule at the bottom of the stack means every later slice that
calls Toucan directly fails the Clj-Kondo job when it merges into 01 --
warnings are fatal there. That is ~13 slices, each adding one new
tools/X.clj with a median of 2 t2/ call-sites.

The rule's value is stopping *new* violations, and during the merge window
nobody is writing new MCP namespaces; the code is already written. Enforcing
now converts clean merges into unscheduled refactors.

The redaction fix cherry-picked alongside it stays -- routing those queries
through mcp/db.clj behind an id-splice guard is worth having regardless of
whether a linter enforces the pattern.

Rule text and its exemptions are recorded on GHY-4416 for the closing PR,
which lands it after the burn-down with :level :error rather than :warning.

* Land common.clj's tests alongside the functions

The hoist moved the functions into 01 without their tests, leaving
select-fields, response-format, frontend-url and the list envelope with
no CI signal until slice 08 lands.

Cherry-picks the five deftests and the fields-test projection fixture
from mcpv2-08-tool-search; common_test.clj is now byte-identical to that
branch's version. 10 tests, 67 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156QMhf6CLY92wtY3AfKepV

* Close three coverage blind spots in common_test

truncation-line had no direct test at all — it was only ever reached
through list-content, which never passes :param or :total-floor?. Both
are on the path parameters.clj takes on later slices, so the new
truncation-line-test pins the narrow-with sentence and the "at least N"
floor rendering, plus the two nil cases.

The unknown-path assertion in select-fields-test matched only
#"Unknown field path.*Nearest", so any three catalog entries would have
passed and the Levenshtein ranking — the sole use of the
LevenshteinDistance import — went unasserted. It now pins the leading
two suggestions ("nmae" -> name, collection); the third slot is left
open because collection_path and parameters.name are close enough that
pinning it would test the tie-break, not the ranking.

paths->tree's reduce across distinct top-level keys was unexercised —
the existing merge cases both walk one branch. Added the ordinary
fields: ["name","parameters.type"] shape.

Tests for the three bugs filed against this code (GHY-4420 the
total-floor? boundary, GHY-4421, GHY-4422) are deliberately left out:
they would assert today's buggy behavior, and each belongs with its
fix. The floor case here stays strictly truncated for that reason.

11 tests, 72 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156QMhf6CLY92wtY3AfKepV

* Restore list-content's :empty-hint option ahead of its callers

The hoisted list-content computes its own empty-page sentence and takes
no :empty-hint. Six branches further up the stack (10-duplicate,
16-browse, 19-visualize, 20-oauth, 21-retire-v1, dashboard-write) carry
the design-C list-content that does, and the one caller is
list_databases in tools/browse.clj, which explains that browsing needs
query-builder or table-metadata permission on some database. Restacking
those onto this branch would hand that :empty-hint to a function that
ignores unknown map keys: no compile error, no failing test, the
sentence just disappears. Accepting the option here means the slices
land on a list-content that already honors it.

The two sentences are kept mutually exclusive by construction rather
than by coincidence of the current call site:

  (or (empty-page-line opts) (when (= 0 total) empty-hint))

empty-page-line says "you paged past the end, N exist, page back";
:empty-hint says "there are genuinely zero results, and here is why".
Gating on (= 0 total) is not redundant with the or — without it, a
static hint could print over a paging situation and tell someone with
37 databases that none are visible to them.

The new list-content-empty-hint-test exists so that an incorrect
conflict resolution fails loudly instead of silently. It asserts the
verbatim browse hint text at total 0 (red if :empty-hint support is
dropped again), and that the hint does not displace the empty-page line
at total 37 / offset 100, nor the truncation line on a non-empty page
(red if the guard is "simplified" to a bare or). Both testing strings
name the browse.clj restack they guard.

12 tests, 82 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156QMhf6CLY92wtY3AfKepV

* Gate list-content's :empty-hint on the offset, not just the total

`:empty-hint` is documented as the domain reason a genuinely empty result
set is empty, and the gate was meant to keep it away from a caller who
merely paged past the end. It only checked the total, so it did not.

`empty-page-line` requires `(pos? total)`, so at a total of 0 it declines
for every offset — including a nonzero one. The `or` then fell through to
the hint, and `list_databases` (which forwards the caller's raw offset
through `paged-list-content`) would answer "No databases are visible to
you" for someone who had actually paged past the end of an empty list.
The sentence stays true at a zero total, so this is a contract violation
rather than a false statement, but it is not what the comment claims.

Add the offset to the gate and pin it with a regression test. Verified
the test fails against the previous gate and passes against this one.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* mcp-v2.09a prep: extract the documents Markdown round-trip layer (#81432)

* MCP v2 prep: extract the documents Markdown round-trip layer

Part 9a of the MCP v2 decomposition of #77844. This lands the documents
Markdown layer that the document_write tool (a later slice, 9b) is the first
consumer of, split off so it reviews as a serialization extraction with no MCP
surface. v1 and v2 are untouched; this slice adds no tool.

- NEW metabase.documents.markdown (+ its 929-line test): the CommonMark <->
  ProseMirror AST boundary — parse/serialize/splice, {% card %}/{% entity %}
  token scanning, escaping, a nesting-depth bound. Ported verbatim (byte-
  identical to the source), so its injection / permission-leak / stack-overflow
  hardening is preserved intact.
- metabase.documents.models.document: gains the write orchestration
  (create-document!, update-document!, get-document, clone-cards-in-document!
  and their card-creation helpers). The unrelated explorations content-gating
  block (can-read?/can-write? overrides, the content-visibility cache) is
  deliberately NOT ported — it is a different feature's drift in the same file
  and would change document permission semantics. Two further exploration-
  Summary threads (a stored_result_use.card_id column from an unlanded
  migration; serdes snapshot-embed hardening) are likewise excluded.
- metabase.documents.api.document: card-creation logic moves out of the REST
  layer into the model (519 -> 358 lines); the endpoints become thin wrappers.
  The unchanged REST document api-test is the check (88 tests / 518 assertions).
- metabase.documents.prose-mirror / documents.core: prose-mirror gains
  smart-link-model->db-model; the facade exports the new parse/serialize/splice
  + model fns.
- metabase.comments.core / comments.models.comment: the comment facade gains
  child-target-ids-for-document / comments-for-document / register-context-gate!
  (notify-comment-id! kept — a grep confirmed zero callers, and dropping a
  public re-export is a one-way door).

Fresh-JVM: markdown-test 285 assertions; REST document api-test 518; :module
comments 154; all 0 failures. kondo 0/0; cljfmt clean; modules-config regenerated
(documents module gains :model/Database). The one :module documents error
(collection_test Duplicate key) is a pre-existing deterministic bug in a file
this slice does not touch, reproducing identically in isolation.

* Address review findings in the documents markdown layer

- splice gets the same StackOverflowError -> 400 backstop parse has: the
  re-parse of the edited region runs through flexmark, whose inline parser
  can blow the stack on a long delimiter run in the replacement text.
- A codeBlock :language holding a backtick (which a tilde fence's info
  string legitimately parses into) now re-serializes behind a tilde fence
  instead of silently breaking the fence line, which let fenced
  {% card %} text re-parse as a real embed; a newline in :language
  collapses to a space.
- inline-lines collects per-line chunks and joins once: appending to the
  accumulated string was quadratic in a paragraph's inline node count
  (40k nodes: 1.9s -> 156ms, and it scaled 4x per doubling before).
- attr-pos-long / format-number range-check before (long n), so an
  integral double past long range is a teaching error, not a raw
  IllegalArgumentException.
- update-document!'s :cards schema matches the REST DocumentUpdateOptions
  it backs (:int keys, ignored without :document) instead of narrowing it.
- comments-for-document keeps a deleted parent that still has replies,
  content scrubbed to {}, matching GET /api/comment.
- create-card! back to private (no caller outside the namespace); stale
  smart-link docstrings and a broken namespace link cleaned up.
- Tests for all of the above, plus previously uncovered branches:
  container content-model violations (incl. via splice), splice span
  bounds and the insertion/separator-only arms, token-grammar teaching
  errors, and the entity-token code-mark/unknown-model guards.

* mcp-v2.03 collection_write (#81247)

* MCP v2 tool: bookmark_content

Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).

Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.

Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.

Tests: 9 tool tests + bookmarks REST suite green.

Review question for this PR: one tool — is bookmark_content's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
  pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).

CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).

Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.

--- Round-2 /review-pr response ---

IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).

MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).

MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.

Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.

* MCP v2 tool: collection_write

Adds the v2 collection_write tool — create/update/move/archive collections.

Extraction: collections/update.clj lifts update-collection! out of
PUT /api/collection/:id — the four privates (move-collection!,
archive-collection!, move-or-archive-collection-if-needed!,
maybe-send-archived-notifications!) move verbatim (docstrings de-REST-ified,
notification alias renamed); the endpoint becomes a thin delegator through
the collections.core facade, mirroring the existing create-collection!
pattern. Verified move: bodies diff clean modulo whitespace/docstrings.

The foundation's REST-test changes for this file are all the BROWSE
extraction's repoints, so master's collections_rest api-test runs UNCHANGED
against this branch — that unchanged suite is the extraction check
(fresh-JVM: 202 tests, 941 assertions, green, incl. 23 tool tests).

Deferred to the content tool PR: echo-matches-concise-read-test (the write
echo vs get_content cross-check) — it requires tools/content.

Review question for this PR: one tool — is collection_write's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- v2/resolve.clj grows collection resolution (resolve-collection-id{,-or-personal}).
- v2/write.clj grows `_write` method dispatch (dispatch-write + expand-clear).
- common.clj grows frontend-url. Tests moved alongside (resolve_test, write_test).

Review nits (round 1): fix the self-contradictory `name` field description
("Create only (editable on update)" -> "Required on create; editable on
update"); drop structuredContent from the write echo to match the stack's
text-only convention (no concrete programmatic consumer, no outputSchema).

CI kondo fix: the update-collection! extraction moved the only `tru` caller in
collections_rest/api.clj into collections/update.clj, leaving a dead
`[metabase.util.i18n :refer [tru]]` require — an unused-require warning, which
`mage kondo` (CI's check) exits nonzero on. Removed it.

--- Round-2 /review-pr response ---

IMPORTANT (archived:true + parent_id silently discarded the move) FIXED. The dispatch tested
archived first and returned, so a caller passing both got a trashed collection and no move, with
no signal — defeating the tool's "never believes an ignored field took effect" invariant. It is
now a teaching error naming the fix (move first then archive, or pass parent_id on a later
archived:false restore). Regression test added.

MINOR fixed: the four (is (tool-error ...)) assertions now match the specific expected message,
not any error text; the :name destructure was renamed (coll-name) so it no longer shadows
clojure.core/name.

Fresh-JVM: collection-test 24 tests / 65 assertions / 0 failures; kondo 0/0; modules unchanged.

--- Close-out M5 ---
MINOR (bare tool-error assertion): authority-level-update-test now asserts the specific "paid feature" message (parity with the create-side twin), so a wrong error text fails.

* Route resolve.clj through mcp.db; correct the db.clj docstring

Three fixes from review of the mcpv2-01-core merge resolution.

resolve.clj called t2/select-one directly, so the toucan2 kondo rule this
same change adds shipped already violated by a file it introduces -- and at
the highest-value site, where an agent-supplied id enters a query. Adds
mcp.db/select-one-by-id, which asserts the id is already an integer (entity
ids are resolved before they reach it, so a string there is a bug rather
than a lookup), and routes resolve.clj through it. The rule now flags only
v2/redaction.clj, which predates this branch.

bookmarks/db.clj's ns docstring still claimed every function was "a direct
Toucan 2 call with no additional logic" after the generic dispatch tier
moved in, which is the one part of that docstring a reader relies on.

insert-bookmark!'s unique-constraint rationale moves from its docstring to
an inline comment on the select-or-insert! call it explains.

Tests: bookmarks.api-test + mcp.v2.tools.bookmark-test + mcp.v2.resolve-test,
30 tests / 150 assertions, green.

* readback: refuse an empty read-scopes instead of skipping the gate

`(remove f [])` and `(remove f nil)` both yield `()`, so an empty or nil
`read-scopes` made `missing` empty and returned the full ungated row -- a
gate that did not run was indistinguishable from one that ran and passed.

Not reachable from `bookmark_content`, which passes a literal one-element
vector. It matters because this namespace is shared write-path machinery for
the `_write` tools still to land, and the mistakes a future caller actually
makes -- omitting the argument, or computing `read-scopes` from a per-type
map that misses -- both landed on silently ungated rather than an error.
There is no legitimate empty case, so it throws.

Adds write_test.clj, which the namespace did not have: full-row grant,
degradation (exact key set, so `:name` cannot leak), empty and nil
`read-scopes` refusal, the unrestricted sentinel and nil `token-scopes`,
note pluralization, and an ack-key absent from the row. Verified the
refusal test bites -- removing the guard fails it twice (`[]` and nil) and
leaves the other five green.

Also drops `readback`'s 3-arity, which had no callers, and the ns
docstring's reference to which slice lands next.

Tests: write-test + tools.bookmark-test + resolve-test + bookmarks.api-test,
36 tests / 161 assertions, green.

* Clean up comments in resolve.clj

Removed unnecessary comments for clarity.

* Drop the stray toucan-in-mcp kondo rule from this branch

The rule entered here through the mcpv2-01-core merge resolution rather than
from either parent, and it now lives on mcpv2-00b-shared-infra next to the
mcp/db.clj boundary it governs. Keeping a second copy here only produced seven
false positives on MCP test namespaces, whose toucan2.core requires are fixture
setup rather than the agent-id boundary the rule protects.

Every src/metabase/mcp namespace that touches toucan2.core is already exempt
(db.clj and models.*), so the rule guards nothing on this branch. It comes back
with the shared infra when the stack restacks.

* Route collections/update.clj's app-DB queries through collections/db.clj

The collections module keeps its app-DB queries in db.clj; update.clj still
reached for toucan2 directly. `update!` and the final re-read map onto the
existing `update-collection!` and `collection`; the archived-card sweep and the
move's parent lookup needed new fns, mirroring the query shapes
collections-rest/db.clj settled on.

`t2/hydrate` stays in update.clj — it hydrates the value api/write-check
returns rather than fetching one, matching how measures, segments, and
dashboards handle hydrate after the same refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997

* Collapse parent_id's not-found and no-access answers

`resolve-collection-id` probed existence with no read check and let the
downstream write-check throw, so a token holding only agent:content:write could
tell a nonexistent collection id ("not found") from one it merely cannot write
("You don't have permissions"), and enumerate collection ids across the
permission boundary. POST /api/collection answers 403 either way. The `id`
argument already collapsed both through `resolve-and-read`; `parent_id` now
takes the same path, which keeps the bogus-id guard the probe was there for.

Two existing perms tests asserted the old behavior by pointing at another
user's personal collection; they now use a readable-but-not-writable collection,
so they still prove the write check fires without depending on the leak. The
unreadable-parent case is what the new test covers.

Also repairs this namespace's tool-result/tool-error helpers, stale since
c521e4c71c changed call-tool to return {:result}/{:error}. That commit updated
bookmark_test.clj but reached collection_test.clj through a merge with no
textual conflict, leaving every error assertion in the namespace failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997

* Drop two collections-rest db helpers left without callers

`cards-in-collection` and `collection-location-columns` each had exactly one
caller, inside the two private fns this branch moved to
`metabase.collections.update`. The collections module cannot require
collections-rest, so update.clj carries its own copies; these are dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997

* Refuse a parent_id move that would leave a collection in the trash

Archiving sets only archived/archived_directly/archive_operation_id, so a
trashed collection keeps its pre-trash :location. An update passing parent_id
without archived therefore skipped the archive branch, moved the collection
against that stale location, and echoed the new parent while the collection sat
in the trash — nothing appeared under the new parent.

`archived: true` + parent_id was already rejected; this is the same
contradiction from the other side, so it gets the same teaching error, pointing
at the `archived: false` + parent_id restore path that already works.

The check needs the collection's current state, so it can't join the pure
arg checks in check-method-args!. It takes the collection resolve-existing
already returned rather than fetching its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997

* Pin the write-echo degradation and the collection-id not-found collapse

Both properties were load-bearing and unasserted. The scope-gating test drove a
write-only token but checked only that :id was an int, which holds whether or
not the readback gate runs — dropping the gate, or widening its ack-keys to
leak :name, failed nothing. resolve-collection-id's main branch had no coverage
at all; the file tested only the nil/"root"/"trash" sentinels.

Each test was verified by mutation: removing the readback call reddens the
degradation test on both methods, and reverting the id collapse to
resolve-id-or-404 reddens the not-found test on the message-equality assertion
that is the actual security property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997

* Close collection_write's namespace argument to the one namespace it documents

Any non-blank string was accepted and passed straight through to the create.
assert-valid-namespace only checks a collection against its parent, so a
root-level create with a typo passed: namespace "snippet" produced a collection
in a hierarchy nothing surfaces, and the echo confirmed the typo back — the one
thing the tool's contract says must never happen.

[:enum "snippets"] mirrors authority_level's [:enum "official"] two entries
below, and puts the allowed value in the tool schema where most clients will
reject a typo before it reaches us. transforms is left out deliberately: those
collections are created by the transforms machinery and carry its lifecycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZzZhTfVjp6ZGe9fBfK997

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [mcp-v2 09b] document_write tool

Second half of split slice 09 (DEC-0001). 09a landed the documents Markdown
round-trip layer; this adds the thin MCP tool that drives it.

Review question: does the write tool enforce document-level write permission
before it reaches the docstring-only-guarded model fns, and does it refuse to
launder a foreign card into a document the caller cannot write?

Surface:
- src/metabase/mcp/v2/tools/document.clj — the document_write handler.
  create! runs api/create-check before documents/create-document!.
  update! runs read-check (resolve-and-read-with) -> check-not-archived ->
  api/write-check -> validate-collection-move-permissions, all before
  documents/update-document!. Card-embed and smart-link resolution are
  permission-checked (mi/can-read?); a foreign readable card is cloned, never
  referenced; replace_all is bounded by max-replace-all-work.
- :document projection registered here (first mcp consumer); content/11 drops
  its duplicate when it lands.

Inherited 09a-review finding (closed): the documents.core model fns enforce
permission by docstring only. 09b is their first direct caller, so the tool
carries the checks. permission-denied-write-leaves-no-db-trace-test asserts DB
state (no Document row, no cloned Card) on a refused write, not just :isError;
verified fails-when-broken by removing api/write-check.

Deferred tests (restore with slice-11/content): create-clones-card-test,
write-echo-and-read-name-the-body-alike-test.

Gates: document-test 80 assertions, :module mcp 5280 assertions, kondo 0,
cljfmt clean, modules config regenerated (mcp :uses comments+documents,
comments :api gains comments.core).

--- Correctness fix (round-1 review, critical) ---
apply-edit/replace-all spliced new_str into Markdown source as-is. splice re-parses the
edited region, so a replacement like "*" or a leading "#" reopened the block as a list or
heading and could drop a character (the "*" consumed as a list marker) — a text-only edit
silently corrupting structure and losing data. Reproduced: body "x x x x x" + edit x->*
replace_all yielded a bulletList with "* * * *" (4 not 5).

Fix: new public documents/escape-text (markdown.clj) escapes a raw string to its literal
form for any position — inline emphasis/link/code chars per line, plus a per-line leading
block-start escape. apply-edit escapes new_str before splicing; over-escaping a char not in
a special position is inert (CommonMark renders \# as #). splice itself is unchanged (its
markdown-source contract is load-bearing for heading/card-embed inserts — verified 09a
markdown-test stays green). New edit-replacement-is-literal-text-not-markdown-test locks it,
proven fails-when-broken.

* Resolve user smart links under the mention picker's visibility rule

`smart-link-readable?` let any non-sandboxed caller resolve any `:model/User` row, and the stored
label falls back to `:common_name` — the email address when a user has no name. A document author
could therefore enumerate names and emails across tenants, deactivated accounts and API-key
accounts included, by writing `{% entity model="user" %}` tokens and reading the labels back.
User rows are now selected the way `GET /api/comment/mentions` and `GET /api/user/recipients`
select them: a superuser sees everyone; anyone else sees active personal accounts in their own
tenant, narrowed by the `user-visibility` setting. Tests cover a deactivated user and
`user-visibility :none`. Modules config regenerated (mcp now uses users).

* Don't escape an edit that lands inside code

`apply-edit` escaped `new_str` unconditionally so a replacement like `*` or a leading `#` could not
reopen the block as a list or heading. That reasoning holds in prose and inverts inside code:
backslashes are literal in a code span or fenced block, so the escaping stored characters the caller
never wrote. `{old_str "ls", new_str "my_var"}` against "run `ls -la` now" stored
"run `my\_var -la` now" — verified by driving apply-edit before the fix. Snake_case identifiers in
code are the common case.

Escaping is now decided per match: `in-code-context?` counts unescaped backticks and fence lines
before the offset, and `replace-all` prices each occurrence against the serialization it is splicing,
so one match in prose and another in a code span each round-trip correctly.

Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va

* Enable the feature the user-visibility assertion depends on

The smart-link test narrowed `user-visibility` to `:none` and asserted that another user's name stops
resolving. But `user-visibility` is gated on `:email-restrict-recipients`, so in an OSS build the
setting reads `:all` whatever is stored: the narrowing never happened and the assertion pinned the
default instead of the rule. It passed locally and failed every OSS App DB CI job.

Wrapped in `mt/with-premium-features`, matching how the other `user-visibility` tests set it up.

Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va

* Fix document_write Markdown table handling

Reject GFM tables in full document bodies and edit replacements before persistence. Advertise the limitation in the tool description.

* fix(mcp): parse document edits as Markdown

* Budget document_write's edit work per call, not per edit

`max-replace-all-work` was checked once inside `replace-all`, so every entry
in `edits` got its own fresh allowance. `edits` is an unbounded list, which
makes that no bound at all: 100 edits each priced just under the ceiling buy
100x the work in one request.

The ceiling now covers the call. `apply-edit` takes and returns
`{:ast :spent}`, pricing each edit against the total the earlier ones have
already spent. It has to be spent down incrementally rather than totalled up
front — an edit's match count is only knowable against the text its
predecessors produced.

Plain edits are priced too. A single splice still re-serializes the whole
document, so `edit-work` counts splices (one for an edit, one per occurrence
for a `replace_all`) and both kinds draw on the same budget.

`replace-all` no longer serializes to price itself. It takes the
serialization and matches `apply-edit` already computed, which drops a full
redundant serialization per `replace_all` and makes its docstring's "one
serialization rather than one per match" true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fD5YPdm6TNR2WfU3k7MMB

* Follow collection_write's contract changes into document_write

Slice 03 changed two shared contracts under this branch. Both merged clean
and both were invisible to a targeted kondo run, which cannot resolve vars
across namespaces.

`v2.write/readback` gained an `ack-keys` parameter — caller-supplied keys
that survive when the response degrades for a token without the read scope.
`document_write` has none: nothing it echoes back is caller-supplied rather
than read-gated, so it passes nil, as collection_write does.

`registry/call-tool` now returns `{:error …}` when the registry refuses
before dispatch and `{:result mcp-content}` after the handler runs, so
`:isError` and `:content` moved under `:result`. The test helpers still read
them at the top level, which made every refusal look like a success and hid
both budget tests behind a vacuous pass. `error-text` reads either shape from
one place: a schema violation refused at the boundary carries `:error`, a
handler's teaching error carries `:result {:isError true}`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fD5YPdm6TNR2WfU3k7MMB

* Record a recent view when a document is created

`command palette > should display collection names for documents in recents`
was failing on this branch (2 of 2 runs), passing 5/5 on master with a
byte-identical spec.

The documents extraction swapped `get-document` for `read-check` +
`hydrate-document` on the create path, so `:event/document-read` stopped
firing. That event's handler is the only thing that writes the `RecentViews`
row (documents/view_log.clj), and the command palette surfaces a just-created
document only through recents — the spec POSTs a document and never visits it.

Creating is arguably not reading, but recents rides on the same event, and
UXW-1786 expects a new document to be findable. Restored `get-document` and
dropped `post-document-does-not-record-view-test`, which pinned the new
behaviour; it and the e2e spec cannot both hold. The POST/PUT symmetry that
motivated it does not apply: PUT never recorded a view on master, POST always
did.

Added `post-document-records-a-recent-view-test` beside the GET/PUT view tests
so the expectation is asserted at the API level — the Cypress spec reports an
empty palette without saying why.

Decoupling recents from `:event/document-read` would let view counts stop
treating creation as consumption; that is a separate change.

* Make the session-signature tests capable of failing

The two signature assertions in `mcp-ui-capability-claim-must-be-signed-test`
passed for the wrong reason, so they proved nothing about signing.

`#"\\."` matches a literal backslash followed by any character, not an escaped
dot, so the split was a no-op: `uuid` received the whole session id and
`payload`/`sig` were nil. The reconstructed ids were malformed and rejected
before signature verification ever ran. Replacing `session-payload-signature`
with a constant left all six assertions green.

Fixing the split alone was not enough. The tampering case re-encoded the SAME
payload onto an already `ui true` id, so it rebuilt byte-identical and was
believed for the right reason. It now mints a `ui false` id and swaps in a
`ui true` payload under the original's signature, which is the escalation the
signing exists to stop.

Guards added so this cannot silently rot again: the splits assert they reached
a signature, the lifted signature asserts it differs from the original, and a
control asserts the unmodified id claims nothing.

Verified by mutation: with `session-payload-signature` stubbed to a constant
the test now fails 3 assertions, where it previously passed clean.

* Pin the transform search gate without writing a global setting

transform-visibility-is-superuser-only-test wrapped its body in

    (mt/with-temporary-setting-values [transforms-enabled true]
      (mt/with-premium-features #{:transforms-basic} ...))

which left `transforms-enabled` explicitly set for the rest of the JVM, and
took metabase.metabot.tools.transforms-test and metabase.revisions.api-test
down with it on every backend shard.

`with-temporary-setting-values` does not restore a setting to *unset*. It
captures `(setting/get setting-k)` on entry and writes that value back in its
`finally` (test/metabase/test/util.clj:552-609). `transforms-enabled` is unset
here, so the capture runs its getter's `cloud-enabled-transforms?` fallback
((and (is-hosted?) (has-feature? :transforms-basic))) and yields a computed
boolean rather than nil. Restoring that boolean persists a real Setting row,
so `explicit-transforms-enabled` stops returning nil and the fallback every
other transform test relies on is dead for the remainder of the run.

The capture also happened before any premium features were stubbed -- the
setting form was the outer one -- so it read ambient state, not
#{:transforms-basic}.

Downstream that lands on the permission check: `transform-readable?`
(src/metabase/transforms/models/transform.clj:35-42) gates on
`check-feature-enabled` *ahead* of the `api/*is-superuser?*` bypass, and that
gate begins with `(setting/get :transforms-enabled)`
(src/metabase/premium_features/token_check.clj:715-730). Once the setting is
pinned false, even admin callers are denied, which is why the revisions test
saw 403 on its superuser case and the metabot transform tools returned
"You don't have permissions to do that.".

Pin the gate at its actual seam instead: redefine
`transforms.gating/enabled-source-types`, which is the single place
search/impl.clj:364 reads it from. That covers what the test needs -- the
in-place engine drops transform for everyone when the enabled set is empty --
without touching persisted state, so there is nothing to restore and nothing
to leak.

Adding `:hosting` to the feature set would also have made the leaked value
harmless, but it only changes *which* value leaks; the setting would still
come back explicitly set. The underlying defect in
`do-with-temporary-setting-value!` (capturing the computed value rather than
the raw stored one) is left for a separate change -- it has ~229 call sites
and wants its own audit.

* Move the document comment queries into comments.db

Master added a clj-kondo hook requiring Toucan2 query calls to live in
`metabase[-enterprise].<module>.db`. This branch had added two `t2/select`
calls to `comments/models/comment.clj`. Neither side fails alone; merging the
new rule with the existing calls turned CI's Clj-Kondo job red with two
warnings.

`document-comments` and `document-child-target-counts` now live in
`comments/db.clj` alongside the module's other queries. The model functions
keep their logic — the deleted-comment scrub, `:creator` hydration, the
`select-keys` projection — and delegate only the query.

Full-tree kondo: 0 errors, 0 warnings; no other file violates the new rule.
:module comments 174 assertions, 0 failures / 0 errors. :module documents
1408 assertions with only the two pre-existing `collection-test` errors, which
predate this change.

* Move document_write's Toucan calls into mcp.db

#82099 added `:metabase/t2-query-namespace`, which confines application
database query calls to `metabase[-enterprise].<module>.db`. It arrived with
the master merge and made this file the only violator in the tree — three
warnings, and CI requires zero.

`select-by-ids` covers the two `:in` lookups and mirrors the existing
`select-one-by-id`, which already takes a dynamic model.

`select-users-where` takes an assembled HoneySQL clause, which is looser than
the "direct Toucan 2 call with no additional logic" the namespace documents.
The clause is built from the `user-visibility` setting, group membership and
the superuser flag — it decides which users the caller may resolve at all, so
it is a permission decision. Moving it into a db namespace would hide it and
pull those dependencies in. #066c63b88ee, the comments move on this branch,
set the precedent: the logic-bearing fn stays, only the query moves.

`select-by-ids` deliberately has no `{:pre [(seq ids)]}` despite
`select-one-by-id` guarding its argument. An AssertionError is not an
Exception, so it would bypass the `catch Exception` in `smart-link-rows` and
turn a logged warning into a propagated failure — a behavior change this
relocation should not smuggle in. Callers guard the empty case instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fD5YPdm6TNR2WfU3k7MMB

* mcp-v2.04 prep: portable-dialect execution rework in the agent lib (#81248)

* MCP v2 tool: bookmark_content

Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).

Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.

Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.

Tests: 9 tool tests + bookmarks REST suite green.

Review question for this PR: one tool — is bookmark_content's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
  pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).

CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).

Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.

--- Round-2 /review-pr response ---

IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).

MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).

MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.

Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.

* MCP v2 tool: collection_write

Adds the v2 collection_write tool — create/update/move/archive collections.

Extraction: collections/update.clj lifts update-collection! out of
PUT /api/collection/:id — the four privates (move-collection!,
archive-collection!, move-or-archive-collection-if-needed!,
maybe-send-archived-notifications!) move verbatim (docstrings de-REST-ified,
notification alias renamed); the endpoint becomes a thin delegator through
the collections.core facade, mirroring the existing create-collection!
pattern. Verified move: bodies diff clean modulo whitespace/docstrings.

The foundation's REST-test changes for this file are all the BROWSE
extraction's repoints, so master's collections_rest api-test runs UNCHANGED
against this branch — that unchanged suite is the extraction check
(fresh-JVM: 202 tests, 941 assertions, green, incl. 23 tool tests).

Deferred to the content tool PR: echo-matches-concise-read-test (the write
echo vs get_content cross-check) — it requires tools/content.

Review question for this PR: one tool — is collection_write's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- v2/resolve.clj grows collection resolution (resolve-collection-id{,-or-personal}).
- v2/write.clj grows `_write` method dispatch (dispatch-write + expand-clear).
- common.clj grows frontend-url. Tests moved alongside (resolve_test, write_test).

Review nits (round 1): fix the self-contradictory `name` field description
("Create only (editable on update)" -> "Required on create; editable on
update"); drop structuredContent from the write echo to match the stack's
text-only convention (no concrete programmatic consumer, no outputSchema).

CI kondo fix: the update-collection! extraction moved the only `tru` caller in
collections_rest/api.clj into collections/update.clj, leaving a dead
`[metabase.util.i18n :refer [tru]]` require — an unused-require warning, which
`mage kondo` (CI's check) exits nonzero on. Removed it.

--- Round-2 /review-pr response ---

IMPORTANT (archived:true + parent_id silently discarded the move) FIXED. The dispatch tested
archived first and returned, so a caller passing both got a trashed collection and no move, with
no signal — defeating the tool's "never believes an ignored field took effect" invariant. It is
now a teaching error naming the fix (move first then archive, or pass parent_id on a later
archived:false restore). Regression test added.

MINOR fixed: the four (is (tool-error ...)) assertions now match the specific expected message,
not any error text; the :name destructure was renamed (coll-name) so it no longer shadows
clojure.core/name.

Fresh-JVM: collection-test 24 tests / 65 assertions / 0 failures; kondo 0/0; modules unchanged.

--- Close-out M5 ---
MINOR (bare tool-error assertion): authority-level-update-test now asserts the specific "paid feature" message (parity with the create-side twin), so a wrong error text fails.

* MCP v2 prep: portable-dialect execution rework in the agent lib

The shared machinery behind every portable-MBQL write path — metabot's
construct_notebook_query today, and the v2 MCP tools (metric_write,
question_write, execute_query) as they land on the stack:

- metabot/tools/construct: execute-representations-query reworked (new
  signature; numeric-id source detection; recovery-hint wrapping around
  resolution failures).
- NEW metabot/tools/recovery_hints: the standard 'what to call next'
  sentences appended to dialect teaching errors.
- agent-lib representations{,.repair}: the corresponding repair/resolve
  pipeline changes (resolve.clj needed nothing beyond master's).

Master's post-fork pMBQL→'MBQL 5' doc/message renames are re-applied on
top of the pulled files (wholesale pulls from the foundation would have
reverted them).

Behavior check: master's agent-lib suites (repair_test, resolve_test) and
metabot tool tests run UNCHANGED and green in a fresh JVM.

Review question for this PR: does construct_notebook_query (the existing
metabot consumer) behave identically where not deliberately extended?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Review fixes (adversarial review round 1):
- F1: the agent-api /v2/construct-query endpoint and the slackbot query tool
  called execute-representations-query without :recovery-hint, so both v1
  surfaces relayed bare pipeline errors where slice 03 had baked guidance into
  the throw sites. Wired recovery-hints/recovery-hint into both (as the ns
  docstring already claims every v1 entry point does). Proven: a URI-in-
  source-table error now carries the "use the portable FK" sentence on the
  agent-api path.
- F4: node-table-fks never collected a numeric field ref's target table, so it
  was never permission-checked; the no-fk-path error then named it from the
  un-sandboxed metadata provider — a table-name oracle for any probed id once a
  numeric binder lands. Fall back to the bare id for the numeric case (matching
  the vector case's own-name behavior). Dark today (no binder on this slice),
  fixed before it can go live.

Test fix: the F1 change gave slackbot's execute-representations-query call a
second (:recovery-hint) arg, but slackbot_query_test stubbed the fn with 1-arg
lambdas — the extra arg broke the stubs (nil results). Made the 4 stubs
variadic ([external-query & _]) so they accept the opts map; production change
unchanged. Suite green (53 assertions).

--- Round-2 /review-pr response ---

CRITICAL (numeric field refs bypass the pre-repair permission check) FIXED. A bare numeric field
id in a query node was never resolved to its table, so node-table-fks skipped it and query-check
never gated the field's table. node-table-fks now emits the source-field as a tagged numeric ref;
resolve-table-fk resolves it to the field's table via the permission-aware metadata provider and
checks that table. Two regression tests: one asserts the FK-target table is permission-checked,
one asserts a forbidden FK-target table blocks the call.

CRITICAL (export-source-field-portable writes an un-permission-checked FK path) REFUTED, and the
refutation is closed by the fix above. The exported source-field is the FK-BEARING column, which
by definition lives on the source table — the same table node-table-fks now collects and
query-check gates. Verified in a fresh JVM: the exported field resolves to a column on the source
table, not the FK-target; denying perms on the target raises 403 before export runs. The reviewer
conflated the FK-target table with the FK-bearing column.

IMPORTANT fixed: the recovery_hints ns docstring no longer asserts wiring that does not exist (it
now states the v2 query slice must pass it, and that until then it has no production caller); the
:no-fk-path message and the :unknown-table key collision are corrected. MINOR fixes applied.

Fresh-JVM: construct-representations-test + repair-test 228 tests / 616 assertions / 0 failures;
kondo 0/0; modules unchanged.

* Read-check the numeric source-card unconditionally

The numeric `source-card:` branch guarded its read check on
`api/*current-user-id*` being bound, so a caller with no current user got the
card's database id back without any permission check. Its portable sibling,
`tools.u/get-card-by-entity-id`, read-checks unconditionally — two branches of
one function disagreeing on whether permissions apply.

The guard was not there to avoid a crash: `read-check` -> `mi/can-read?`
resolves Card perms against the current-user permission set and denies when
there is no user, so removing the guard fails closed.

Not reachable in production yet — nothing binds `*numeric-ids-allowed?*` to
true until the v2 query slice lands — but this branch is where the dialect is
authored, so it is where the check belongs.

Adds the numeric `source-card` coverage the other numeric branches already
had: database-id resolution, that the card row reaches `read-check`, a denied
card surfacing 403, the same with `*current-user-id*` nil as the regression
guard, and an unknown id surfacing `:unknown-card`. Also covers two branches
that had none: `:unknown-table-id` for a numeric `source-table` that resolves
to nothing (kept distinct from the portable form's `:unknown-table`, since the
two want different recovery vocabulary), and `with-recovery-hint` passing an
error through untouched when there is no hint to add.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nng1SV562GGqn1Qf8dQmpM

* Route numeric source-card through the read-checked content store

The numeric-id dialect accepts a bare numeric `source-card`, but
`import-mbql` matched only `portable-id?`, which requires a string. A
numeric id therefore never reached the ContentStore -- and on agent
paths the store is the `read-checked` one, so that lookup is where the
permission check lives. The only remaining check was the explicit
`read-check` in `resolve-database-id-from-first-stage`, which by
construction sees `stages[0]` only, and which a stage carrying both
`source-table` and `source-card` skips entirely (the `cond` takes the
`source-table` branch).

Downstream, the repair pass's cross-stage column inference runs for any
stage with a `source-card` and reports the card's column names when a
referenced column is missing, so an unchecked card surfaced its columns
to a caller without access to it.

Fix at the resolution chokepoint rather than at the first stage:
`import-mbql` now has a numeric `source-card` branch, gated on
`*numeric-ids-allowed?*` so the portable-only surface is untouched.
Because that walk recurses, this covers every stage and every join
instead of just the one the database-id lookup happens to inspect. The
new `import-card-by-id` mirrors `import-card-by-entity-id`, including
its cross-database guard, and returns the id it was handed -- the
routing, not the translation, is the point.

Also drop the `(when api/*current-user-id* ...)` guard on the numeric
card's `read-check` in `construct.clj`. The entity_id branch checks
unconditionally and the numeric table branch fails closed; only this one
failed open for an unauthenticated caller.

Verified against a real appdb with an unprivileged user: both shapes now
raise 403 with no column names in the message or ex-data, where before
they resolved and reported them. The new tests fail on the unfixed tree
and pass here.

Test evidence: resolve.mp-test 41 tests / 136 assertions;
construct-representations + repair + resolve 249 tests / 707 assertions;
metabot tools 28 tests / 386 assertions; new
construct-numeric-permissions-test 4 tests / 11 assertions. All green.

* Require numeric ids to denote something the query may reference

Three findings from the slice-04 review, all the same bug: the numeric
dialect treats an LLM-authored integer as a resource reference and acts
on it directly. The portable dialect never had to state these rules --
it resolves *names* through a metadata provider that is already scoped
to the query's database and filtered to visible rows, so "in this
database" and "a column the warehouse exposes" fall out of resolution.
A bare id skips all of it.

`visible-table` / `visible-field` put the contract back: everything the
numeric dialect accepts is looked up through the query's own provider,
and the active/visibility rules are re-applied on top, because
`metadata-spec->honey-sql` deliberately drops its filter for by-id
lookups (`active-only?` is `(not (or id-set name-set))`). That default
is right for trusted callers asking for a specific row and wrong for an
agent-authored id.

1. Cross-database tables. `resolve-table-fk` returned a `pos-int?`
   verbatim, so a join could pull a table from another database into a
   query. `api/query-check` does not close this: it asks whether the
   caller may query table N, never whether N belongs to this query's
   database, so it passes for anyone with access to the other database.
   Traced on the unfixed tree, the check ran against the foreign table
   (id 9, db_id 2) inside a query targeting db 1 and passed; the query
   failed later only because repair could not find the table in the
   wrong provider. The refusal was incidental, not a boundary.
   `resolve/mp.clj` has cross-database guards for cards, measures and
   segments and none for tables, because the portable path never needed
   one.

2. Sensitive / inactive / retired fields, and inactive or hidden tables,
   were reachable by id and came back as result columns. The portable
   dialect cannot name them at all -- they are absent from the by-name
   fetch.

3. Table-existence oracle. The numeric branch of
   `resolve-database-id-from-first-stage` distinguished "no such table"
   from "table you cannot see" before any permission check had run,
   which enumerates table ids instance-wide. It now read-checks and
   reports both the same way. This lookup cannot go through the provider
   -- it is what decides which provider to build -- so the check lives
   there.

`assert-numeric-ids-are-referenceable!` raises the agent-facing errors
ahead of the permission sweep; `resolve-table-fk` independently refuses
the same ids, so the sweep fails closed even if a future caller skips
the assertion. Both new error keys already have recovery hints on both
surfaces. No-ops entirely on the portable-only surface.

Test evidence: 9 tests / 21 assertions in the new ns, all green, and 11
assertions across all three findings fail on the unfixed tree. Existing
suites unchanged: construct-representations + repair + resolve +
resolve.mp, 290 tests / 843 assertions, 0 failures.

Also corrects two wrong premises the tests caught: a query whose only
source is a foreign table is not a violation (the provider is simply
built for that database), and
`with-non-admin-groups-no-root-collection-perms` does not gate warehouse
tables, so the oracle test revokes data permissions instead.

* Read-check numeric metric, segment, and measure refs too

Follow-up to the numeric `source-card` fix, from review feedback: the
same gap existed for every other content reference, not just the source.

`import-mbql` guarded its content branches with `portable-id?`, which is
string-only. A numeric `["metric" {} 42]`, `["segment" {} 42]`, or
`["measure" {} 42]` therefore matched no branch at all -- it was never
resolved, so it never reached `import-fk`, so the read-checked content
store never saw it. Same shape as the `source-card` leak, across seven
more branches.

Rather than give each branch a numeric twin -- fourteen branches, and a
later one easy to forget -- the guard itself widens: `content-ref?` is
"a portable entity_id, or a bare id on the numeric-id surface", and
every content branch shares it. `import-fk` then dispatches numerics per
model, so `import-card-by-id` gains `import-measure-by-id` and
`import-segment-by-id` siblings, each carrying the same cross-database
guard as its portable counterpart. Metric refs resolve as Cards, which
is what they are.

Also from review:
- `:unknown-card-id` for a numeric card miss, matching `export-card-by-id`
  and the `:unknown-table-id` precedent -- a numeric miss and a portable
  miss want different recovery advice. Updates the one test that asserted
  the old key.
- `resolve-database-id-from-first-stage`'s docstring said `:unknown-table`
  where the code throws `:unknown-table-id`.
- Dropped a `^clojure.lang.ExceptionInfo` hint on `with-recovery-hint`
  that propagates nowhere.
- `numeric-field-id-error!` no longer claims lib rejects bare numeric ids,
  which stopped being true once the numeric surface landed.

The read-check on the numeric `source-table` lookup moved into
`metabot.db/readable-active-table-database-id`, keeping Toucan calls in
the db namespace where this branch put them.

Test evidence: 308 tests / 883 assertions across
construct-numeric-permissions, construct-representations, repair,
resolve, and resolve.mp -- all green.

* Correct a test comment about the PathSchema stack overflow

The comment called it a nested-quantifier backtracking problem. It is
not: `path-char-rx` excludes `/`, so segment boundaries are forced and
there is nothing to backtrack over — a 218-character failing input
matches in 0 ms. java.util.regex simply recurses once per quantifier
iteration on a *successful* match, so depth grows linearly with segment
count (~2000 segments overflows a default stack on its own). Atop the
pipeline's own frames the threshold drops far enough to hit it here.

Same conclusion either way — pre-existing, dev/test only, unrelated to
this branch — but the stated mechanism was wrong and would have sent the
next reader after a bug that isn't there.

* Say why the visibility lookups swallow every exception

Review noted the catch-all was undocumented. It is deliberate: this pass
decides only what a numeric id may name, so failing closed is the safe
direction and a provider outage is diagnosed elsewhere. Say so, rather
than leaving a bare `(catch Exception _ nil)` to read as an oversight.

* Drop a redundant do in a test helper

`try` already has an implicit body; kondo flagged it.

* Build the test fixtures with Lib instead of mt/mbql-query

`mt/mbql-query` is deprecated since 0.61.0, and clj-kondo exits 2 on
warnings — so the two call sites this ns added were failing the
Clj-Kondo CI job outright, not merely producing noise. Its own docstring
says to use Lib for new tests; done.

Same queries, same result columns: the victim card keeps its named
`SecretMargin` expression and its aggregation, which is what makes a
column-name leak unmistakable in the assertions.

Full-repo kondo now reports 0 errors, 0 warnings (was 2 warnings, exit 2).

* Close four numeric-id permission gaps found in review

All four are in the numeric-id handling this branch added.

**Two-element field refs escaped collection.** `node-table-fks` checked the
portable form in slots 2 *and* 1 but the numeric form in slot 2 only. Repair
inserts the options map later, so an LLM-authored `["field", 42]` still carries
its target in slot 1 when the collection pass runs — that shape reached
resolution unchecked. Both forms now read both slots.

**Field visibility ignored the parent chain.** A JSON column unfolds into child
fields carrying their own visibility, so an ordinary-looking child of a
`:sensitive` parent was referenceable by id — reaching inside a column the
warehouse marked unqueryable. `visible-field` now walks `:parent-id` and
requires every ancestor to be visible too, bounded so a corrupt chain cannot
spin.

**Unreadable and missing content were distinguishable.** A denial surfaced
`read-check`'s 403 while an absent id produced a 400 `:unknown-…`. Content ids
are sequential and easy to guess, so the status code alone told a caller whether
a hidden card, measure, or segment exists — the same oracle already closed for
the numeric table lookup. `maybe-read-check` now collapses a 403 to `nil`, which
every resolver already translates into its not-found error, so one change covers
all six store methods; non-403 exceptions still propagate, since those are real
errors rather than permission answers. `resolve-database-id-from-first-stage`
read-checks outside the store, so it got the same collapse.

**The table lookup denied users it should have allowed.** `mi/can-read?` for a
Table is polymorphic over the instance, and its published-collection branch reads
`:is_published` and `:collection_id`.
`readable-active-table-database-id` selected only `:id`/`:db_id`, so that branch
evaluated against absent keys and a user whose access came through a published
collection was told the table does not exist. It selects the whole row now. This
one was a fail-closed regression introduced by the previous commit's oracle fix.

Tests: four new cases, each verified to fail on the pre-fix tree. Existing tests
that asserted the 403 shape are updated to the collapsed one — their intent (the
check runs, fails closed, does not depend on a bound user, never leaks an
entity_id) is unchanged and still asserted.

319 tests / 935 assertions green across construct-numeric-permissions,
construct-representations, repair, resolve, resolve.mp, and content-store.
Full-repo kondo: 0 errors, 0 warnings.

* Close the query-check oracle and cover scoped content refs

Second review round. Both blocking findings are the same root cause as the
one the previous commit fixed — `mi/can-read?` is strictly weaker than
`mi/can-query?` for a Table, since only `can-read?` carries the
`manage-table-metadata :yes` disjunct — reached by two routes that fix did
not cover.

**The table oracle was still open one line later.** The previous commit
closed it at `readable-active-table-database-id`, which gates on
`can-read?`. `check-source-table-query-permissions!` runs immediately after
and gates on `api/query-check` → `can-query?`. So a metadata-only user
passed every earlier gate and got a bare 403 there, while a nonexistent id
got a 400 — confirming the table exists, is active, and is in this database.
`query-check-table!` now collapses a denial on a *numeric* reference into the
same `:unknown-table-id` an absent id produces. The portable path keeps its
403: its references are unguessable names, so there is nothing to enumerate
and the accurate status is worth more.

**Numeric measure and segment refs never reached a query-check at all.**
`node-table-fks` matched maps and `["field" …]` vectors, so `["measure" {} 44]`
and `["segment" {} 43]` fell through both branches — the tables they are
scoped to were never collected, and `assert-numeric-ids-are-referenceable!`
iterates the same collection, so it skipped them too. They do get
`api/read-check` through the store, but `can-read?` on both models delegates
to the Table's `can-read?` and lands on the same weaker gate. Adds a
`::scoped-content` tag mirroring the existing `::field-id` one, resolved to
the owning table through the read-checked store. Metric refs are Cards and
are deliberately not covered: a card pulls in whatever tables its own query
names, which this pre-flight sweep cannot see, and the QP re-checks them at
execution.

Also from the same review:

- `snippet-id` had been widened to `content-ref?`, but no resolver implements
  `NativeQuerySnippet` — a numeric one fell through `import-fk`'s model tests
  to `not-implemented!` and surfaced as a bare 501 with no `:agent-error?`,
  never reaching the store. Narrowed back to `portable-id?`; `content-ref?`'s
  docstring no longer claims snippets.
- `active-table-database-id` was dead: the pre-oracle-fix version its checked
  sibling superseded within this branch, with the shorter name that reads as
  the obvious choice. Deleted. The one test "stubbing" it was stubbing the
  dead var and hitting the real app DB, passing only because id 999 happened
  not to exist — retargeted to the function actually under test.
- `*numeric-ids-allowed?*`'s docstring described its readers in the future
  tense; this PR is what added them. Rewritten to state the contract instead
  of the rollout.
- `import-card-by-id`'s docstring still said a denial becomes "a 403", which
  the previous commit's collapse made false.
- `visible-field`'s docstring now says the parent walk checks each ancestor's
  visibility but not its `:table-id`, and why that is sound.

Tests: two new cases, both mutation-checked. The segment one deliberately puts
the segment on a different table than the stage's `source-table`, so it cannot
be satisfied by the source table's own check — the first version could, and
passed with the fix reverted.

321 tests / 943 assertions green. Full-repo kondo: 0 errors, 0 warnings.

* Keep the export fallback suppressed for a refused card

The 403 collapse in the previous commits removed a signal an internal
caller depended on. `llm-shape/export-query-for-llm` documents that "a
permission-refused export renders nothing at all rather than the
fallback", and implemented that by catching the 403. Once the content
store turned a denial into `nil`, that 403 stopped arriving, so a card
the caller may not read fell through to `query-edn-fallback` and printed
the raw query map.

No entity_id leaked — the export still refuses to emit one, so the
fallback showed `:source-card 1`, the numeric id the caller already
supplied. But the caller's contract changed, and the intent of that catch
was to render nothing.

The two requirements are not in conflict. Agent-facing, a denial and a
miss must be indistinguishable: same status, same error key, so a
guessable id cannot be used to probe for hidden content. Internally,
callers still need to tell them apart to decide whether falling back is
appropriate. So the collapse keeps the uniform error and records the
refusal out of band, in `*last-lookup-refused?*`, which says only that
*some* lookup was refused — never which row, so it cannot itself become
an oracle.

Caught by `llm-shape-test/transform-query->text-source-card-permission-test`,
which I had not run: it lives outside the affected-set I had been
checking. The second review round flagged this mechanism and I recorded
it as out of scope; it was not.

175 tests / 768 assertions green across llm-shape, content-store,
construct-numeric-permissions, construct-representations, and
resolve.mp. Full-repo kondo: 0 errors, 0 warnings.

* Collapse the read-check denial for numeric lookups only

The collapse was too broad. It lived in `maybe-read-check`, which every
content lookup passes through, so it applied to entity_id lookups as well
as numeric ones — and the two surfaces want opposite things.

Numeric ids are sequential and trivially guessable, so a distinguishable
denial is an existence oracle and the 403 has to become the same
not-found a missing id produces. A 21-character NanoID is not guessable,
so there is nothing to enumerate, and callers there depend on the accurate
status: `POST /api/agent/v2/construct-query` returns 403 for a metric
whose card the caller cannot read, and `llm-shape/export-query-for-llm`
suppresses its EDN fallback on one.

Collapsing both meant the entity_id path started answering 400
`:unknown-card` where it had answered 403 — caught by CI in
`agent-api.api-test/construct-query-permission-checks-test`, which asserts
the 403 for exactly that case.

`maybe-read-check` now takes `collapse-denial?`: true for the three
`-by-id` methods, false for the three `-by-entity-id` ones. The refusal is
still recorded in `*last-lookup-refused?*` either way, so callers that need
to know a denial happened without depending on the status keep working.

This is the second time the chokepoint's breadth cut the wrong way — it is
what made one edit cover all six store methods, and also what swept in a
surface that did not want the change. The split keeps the single
chokepoint and makes the difference explicit at it.

462 tests / 1698 assertions green across construct-numeric-permissions,
construct-representations, repair, resolve, resolve.mp, content-store,
llm-shape, and agent-api. Full-repo kondo: 0 errors, 0 warnings.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* mcp-v2.05 metric_write (#81249)

* MCP v2 tool: bookmark_content

Adds the v2 bookmark_content tool — per-user favorites, keyed by (type, id).

Extraction: bookmarks/api.clj factors bookmark!/un-bookmark!/bookmarked? out
of the REST endpoints so REST and MCP share them. The POST endpoint now
creates via app-db/select-or-insert! (concurrency-safe: a racing duplicate
returns the existing row instead of tripping the unique constraint); its 400
on already-bookmarked is unchanged. REST tests extended, existing pass.

Unlike REST, both tool directions are idempotent (see ns docstring for why),
both read-check the item, and the name echo is gated on the read scope so a
write-only token can't use it as a name oracle.

Tests: 9 tool tests + bookmarks REST suite green.

Review question for this PR: one tool — is bookmark_content's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/resolve.clj — entity-id / numeric-id resolution + the read-check
  pairing with the not-found collapse (+ resolve_test, moved from common_test).
- NEW v2/write.clj — readback (the scope-gated write echo).

CI guard: this slice's mcp -> bookmarks edge put `bookmarks` into the
modules-that-trigger-driver-tests set (mage's module-graph ratchet, 44 -> 45).
Bookmark logic cannot affect driver behavior — the edge is a consumer edge —
so `bookmarks` joins driver-affecting-overrides rather than bumping the
ratchet. Full mage suite green (82 tests).

Review nit (round 1): fetch-item's flavor-mismatch teaching error printed
"is a null" for a card type outside question/model/metric; guard the nil case
with a type-agnostic message.

--- Round-2 /review-pr response ---

IMPORTANT (per-user-test could not fail on its named assertion) FIXED. The test called the tool
bare, so a tool-level error was discarded and the assertions passed regardless. It now routes
both calls through tool-result (throws on tool error) and has :lucky bookmark-then-un-bookmark
its own row, so the "one user's bookmark is invisible to another's un-bookmark" claim has a live
counterfactual. Proven fail-when-broken (stubbing un-bookmark! to a no-op turns the test red).

MINOR fixed: the id schema now requires a positive int (matching REST's ms/PositiveInt); the
degraded write-scope readback now surfaces :bookmarked (via a new backward-compatible ack-keys
arg on v2.write/readback, so a write-only caller still learns the resulting state).

MINOR deferred (pre-existing REST behavior, out of a tool-slice's scope, confirmed structurally):
archived content is bookmarkable but filtered from listings; the Trash collection is bookmarkable;
readback's :url key has no producer on this branch (lands with collection_write). Each is shared
with the existing REST bookmark endpoints; changing it means deciding REST semantics.

Fresh-JVM: bookmark-test 9 tests / 43 assertions / 0 failures; kondo 0/0; modules unchanged.

* MCP v2 tool: collection_write

Adds the v2 collection_write tool — create/update/move/archive collections.

Extraction: collections/update.clj lifts update-collection! out of
PUT /api/collection/:id — the four privates (move-collection!,
archive-collection!, move-or-archive-collection-if-needed!,
maybe-send-archived-notifications!) move verbatim (docstrings de-REST-ified,
notification alias renamed); the endpoint becomes a thin delegator through
the collections.core facade, mirroring the existing create-collection!
pattern. Verified move: bodies diff clean modulo whitespace/docstrings.

The foundation's REST-test changes for this file are all the BROWSE
extraction's repoints, so master's collections_rest api-test runs UNCHANGED
against this branch — that unchanged suite is the extraction check
(fresh-JVM: 202 tests, 941 assertions, green, incl. 23 tool tests).

Deferred to the content tool PR: echo-matches-concise-read-test (the write
echo vs get_content cross-check) — it requires tools/content.

Review question for this PR: one tool — is collection_write's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- v2/resolve.clj grows collection resolution (resolve-collection-id{,-or-personal}).
- v2/write.clj grows `_write` method dispatch (dispatch-write + expand-clear).
- common.clj grows frontend-url. Tests moved alongside (resolve_test, write_test).

Review nits (round 1): fix the self-contradictory `name` field description
("Create only (editable on update)" -> "Required on create; editable on
update"); drop structuredContent from the write echo to match the stack's
text-only convention (no concrete programmatic consumer, no outputSchema).

CI kondo fix: the update-collection! extraction moved the only `tru` caller in
collections_rest/api.clj into collections/update.clj, leaving a dead
`[metabase.util.i18n :refer [tru]]` require — an unused-require warning, which
`mage kondo` (CI's check) exits nonzero on. Removed it.

--- Round-2 /review-pr response ---

IMPORTANT (archived:true + parent_id silently discarded the move) FIXED. The dispatch tested
archived first and returned, so a caller passing both got a trashed collection and no move, with
no signal — defeating the tool's "never believes an ignored field took effect" invariant. It is
now a teaching error naming the fix (move first then archive, or pass parent_id on a later
archived:false restore). Regression test added.

MINOR fixed: the four (is (tool-error ...)) assertions now match the specific expected message,
not any error text; the :name destructure was renamed (coll-name) so it no longer shadows
clojure.core/name.

Fresh-JVM: collection-test 24 tests / 65 assertions / 0 failures; kondo 0/0; modules unchanged.

--- Close-out M5 ---
MINOR (bare tool-error assertion): authority-level-update-test now asserts the specific "paid feature" message (parity with the create-side twin), so a wrong error text fails.

* MCP v2 tool: metric_write

Adds the v2 metric_write tool — create/update metrics from an MBQL
definition (either dialect) or a query_handle.

Extraction: queries/card_write_checks.clj lifts the pre-write check stack
(check-card-can-be-saved!, check-no-save-cycle!, actual-collection-id,
check-allowed-to-create-card!, check-allowed-to-update-card!) out of
queries_rest/api/card.clj (−98; taken wholesale — zero master drift) so
REST and MCP run identical checks. queries/core.clj facade HAND-MERGED:
master's post-fork parameter-template-tag? export kept; foundation's
infer-metadata-with-model-overrides export skipped (no consumer here yet).

Projection ownership fix (the WORKLOG's load-order landmine, hit live):
the :metric key projection moves from content.clj's load-time side effect
into this tool — the type's owner — so metric_write works without
tools/content loaded. The content PR drops its copy.

Deferred tests (see WORKLOG ledger): the execute_query handle flow → query
PR; the get_content echo-parity and definition round-trip → content PR.
card_test's param-fields-hydrated-test is a post-fork master addition and
stays (foundation's deletion is fork lag, not intent).

Fresh-JVM: metric tool suite + agent-lib suites + full card REST suite
green (1155+ assertions across the adjudication runs).

Review question for this PR: one tool — is metric_write's contract right?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Also lands (first consumer, per the common.clj slimming in the core PR):
- NEW v2/queries.clj — the representations-pipeline entry point, portable-dialect
  resolution, and the full query-handle store (mint/encode/decode/resolve, the
  save-path variant this tool consumes; the execute tools add the minting call
  sites in the query PR). Handle tests move alongside as queries_test.

Review finding (round 1, both reviewers): check-metric-shape! rejected only a
PURE-native query (lib/native-only-query?); a native stage nested with an MBQL
stage slipped through to the generic can't-be-saved message (or an incidental
editable? rejection that would open if :native-permissions were ever populated
server-side). Tightened to the whole-tree query-guards/native-query? — the same
guard question_write uses — so any native stage is refused with the metric-
specific message. Regression test added.

--- Round-2 /review-pr response ---

CRITICAL (update! never refreshes result_metadata after a definition swap) REFUTED. Verified in a
fresh JVM: creating a metric then updating it to a different definition (count -> sum with a month
breakout) refreshes result_metadata from a 1-column to a 2-column set. queries/update-card!'s hook
recomputes result_metadata whenever dataset_query changes, so the extraction did not lose the
recompute step. Pinned with metric-write-lifecycle-test asserting the before/after metadata differ.

IMPORTANT fixed: destructiveHint is now true (archived:true trashes the metric; matches the sibling
collection_write tool); the tool no longer emits structuredContent against the stack's text-only
convention; an id on a create call is now rejected rather than silently ignored; collection_position
requires a positive int (matching the downstream contract).

MINOR: applicable fixes applied; the remaining two (a duplicated frontend-url, a kondo allowlist
churn) deferred as cosmetic.

Fresh-JVM: metric-test 25 tests / 79 assertions / 0 failures; kondo 0/0; modules unchanged.

* MCP v2 prep: portable-dialect execution rework in the agent lib

The shared machinery behind every portable-MBQL write path — metabot's
construct_notebook_query today, and the v2 MCP tools (metric_write,
question_write, execute_query) as they land on the stack:

- metabot/tools/construct: execute-representations-query reworked (new
  signature; numeric-id source detection; recovery-hint wrapping around
  resolution failures).
- NEW metabot/tools/recovery_hints: the standard 'what to call next'
  sentences appended to dialect teaching errors.
- agent-lib representations{,.repair}: the corresponding repair/resolve
  pipeline changes (resolve.clj needed nothing beyond master's).

Master's post-fork pMBQL→'MBQL 5' doc/message renames are re-applied on
top of the pulled files (wholesale pulls from the foundation would have
reverted them).

Behavior check: master's agent-lib suites (repair_test, resolve_test) and
metabot tool tests run UNCHANGED and green in a fresh JVM.

Review question for this PR: does construct_notebook_query (the existing
metabot consumer) behave identically where not deliberately extended?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Review fixes (adversarial review round 1):
- F1: the agent-api /v2/construct-query endpoint and the slackbot query tool
  called execute-representations-query without :recovery-hint, so both v1
  surfaces relayed bare pipeline errors where slice 03 had baked guidance into
  the throw sites. Wired recovery-hints/recovery-hint into both (as the ns
  docstring already claims every v1 entry point does). Proven: a URI-in-
  source-table error now carries the "use the portable FK" sentence on the
  agent-api path.
- F4: node-table-fks never collected a numeric field ref's target table, so it
  was never permission-checked; the no-fk-path error then named it from the
  un-sandboxed metadata provider — a table-name oracle for any probed id once a
  numeric binder lands. Fall back to the bare id for the numeric case (matching
  the vector case's own-name behavior). Dark today (no binder on this slice),
  fixed before it can go live.

Test fix: the F1 change gave slackbot's execute-representations-query call a
second (:recovery-hint) arg, but slackbot_query_test stubbed the fn with 1-arg
lambdas — the extra arg broke the stubs (nil results). Made the 4 stubs
variadic ([external-query & _]) so they accept the opts map; production change
unchanged. Suite green (53 assertions).

--- Round-2 /review-pr response ---

CRITICAL (numeric field refs bypass the pre-repair permission check) FIXED. A bare numeric field
id in a query node was never resolved to its table, so node-table-fks skipped it and query-check
never gated the field's table. node-table-fks now emits the source-field as a tagged numeric ref;
resolve-table-fk resolves it to the field's table via the permission-aware metadata provider and
checks that table. Two regression tests: one asserts the FK-target table is permission-checked,
one asserts a forbidden FK-target table blocks the call.

CRITICAL (export-source-field-portable writes an un-permission-checked FK path) REFUTED, and the
refutation is closed by the fix above. The exported source-field is the FK-BEARING column, which
by definition lives on the source table — the same table node-table-fks now collects and
query-check gates. Verified in a fresh JVM: the exported field resolves to a column on the source
table, not the FK-target; denying perms on the target raises 403 before export runs. The reviewer
conflated the FK-target table with the FK-bearing column.

IMPORTANT fixed: the recovery_hints ns docstring no longer asserts wiring that does not exist (it
now states the v2 query slice must pass it, and that until then it has no production caller); the
:no-fk-path message and the :unknown-table key collision are corrected. MINOR fixes applied.

Fresh-JVM: construct-representations-test + repair-test 228 tests / 616 assertions / 0 failures;
kondo 0/0; modules unchanged.

* metric_write update: hydrate moderation reviews like PUT /api/card/:id does

`update-card!` un-verifies a verified card whose query changes by reading `:moderation_reviews`
off the card-before. `update!` passed the bare write-check row, so a verified metric kept its
Verified badge after a definition swap through the tool, unlike through REST. The card-before is
now hydrated the way the REST handler hydrates it.

* Pin the create-side permission stack

`check-allowed-to-create-card!` is the only thing stopping a caller saving a metric into a collection
they cannot curate, or over a query they have no data permission to run — `create-card!` performs no
check of its own. Every create test in this namespace runs as an admin, for whom those checks are
vacuous, so deleting the line left the suite green.

Verified fails-when-broken: removing the call fails both branches of this test.

Claude-Session: https://claude.ai/code/session_018PNCQxp9kCypqE3dLQS9va

* Confine this branch's Toucan calls to db namespaces

New policy: t2 is called only from db.clj namespaces. The two source files
this branch adds each held a couple of direct Toucan calls; both move out.

metabase.queries.db takes the Dashboard collection_id lookup and the
:in_dashboards hydration out of card-write-checks. The local
dashboard-id->collection-id alias was pure indirection once the query moved,
so both sites now call the db fn directly.

metabase.mcp.v2.db takes the Card read-back and the moderation-review
hydration out of the metric tool. The comment explaining why the card-before
is hydrated stays at the call site, where the reasoning belongs.

Nothing outside this branch is touched: resolve.clj and redaction.clj came in
on mcpv2-01-core, and metric_test.clj's Toucan calls are read-back assertions
that deliberately bypass the production helpers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tqiaJmaEXSfaWGUZ4LQLv

* Teach the agent about an empty metric definition

`definition: {}` came back as the sanitized "Internal error", which is exactly
what normalize-definition's docstring promises never to happen for a malformed
definition.

The arg schema is an open map, so `{}` validates. normalize-query's first cond
branch short-circuits an empty query to `{}` before its strict validation runs,
so nothing throws there either. `{}` is truthy, so create!'s "pass the metric's
query" fallback never fires. can-save? is the one that finally objects, by way
of a mu/defn input-schema violation the tool can only report as an internal
error.

Guard the normalized result instead. Both create! and update! reach it through
resolve-definition, so one check covers both.

Strict MCP clients are the ones that hit this: they fill every declared
property, so "no definition" arrives as `{}` rather than an absent key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tqiaJmaEXSfaWGUZ4LQLv

* Let a metric_write caller choose the display

The tool hard-coded :display :scalar on create and never wrote it on update,
with no display argument at all. But the shape gate deliberately accepts one
grouping, so `count by month` saved as a metric page rendering a single number,
unfixable through MCP. agent-api's create_metric and update_metric both took an
optional display, so this was a regression rather than a standing limitation.

Accept an optional display on both methods, defaulting to scalar. The accepted
values are enumerated so an LLM-invented display is an argument error instead of
junk on the card.

The enum is a local copy of agent-api's ::card-display rather than a require:
that namespace is the v1 REST surface this tool replaces and GHY-4250 removes,
and pinning v2 to it for seventeen strings buys a dependency we would have to
undo. No canonical list exists to share instead — four other namespaces already
keep their own.

Echo the written display back. It is a detailed-projection key, but the echo is
the only place a caller sees the scalar they never asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tqiaJmaEXSfaWGUZ4LQLv

* Fold mcp.v2.db into mcp.db to satisfy the app-DB query linter

CI's `./bin/mage kondo` (full tree) flagged src/metabase/mcp/v2/db.clj:12:
"Application database query calls like `t2/select-one` must live in
metabase[-enterprise].<module>.db namespaces". The rule matches
`metabase.<module>.db`, and `metabase.mcp.v2.db` is a segment deeper, so
the module's db namespace already existed one level up as
`metabase.mcp.db` and this was a second one the rule cannot see.

Moved `hydrate-moderation-reviews` into metabase.mcp.db beside the
existing `hydrate-notification`, repointed metric.clj's two call sites
(`card` folds into the existing `select-one-by-id`), and deleted the
duplicate namespace.

Missed locally because the previous run linted only the files the merge
touched; this file came in with the branch and was never in that set.
Reproduced and fixed against the full-tree run CI uses: 0 errors,
0 warnings. Metric suite 30 tests, 95 assertions green. Modules config
unchanged.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Follow the base's contracts after the merge

Three changes the base made to shared namespaces that `question_write` had to
be adapted to; each merged textually clean and broke silently.

`v2.write/readback` dropped its 3-arity in favour of a required `ack-keys`, so
every write threw an ArityException. Passes nil, like collection_write.

`registry/call-tool` now returns `{:result …}`/`{:error …}` rather than raw MCP
content. The test namespace read `:isError`/`:content` off the top level, so its
error-path assertions failed and its success-path assertions passed vacuously. A
local `call-tool` unwraps `:result` and renders a pre-dispatch registry refusal
into the same content shape, which is what the assertions here are written
against.

The `:metabase/t2-query-namespace` rule confines app-db calls to a module's `.db`
namespace, and this file's three `t2/select-one` calls were the tree's only
violations. They go through `mcp.db/select-one-by-id`.

Verified: full `./bin/mage kondo` clean, and the twelve MCP v2 namespaces run
199 tests / 835 assertions green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2

* Fix duplicate_content gate laundering and copy-vs-create semantics

`copy-question!` dissoc'd `:document_id` when building the copy. That column
is not placement -- it is the Card read gate. `mi/can-read? :model/Card`
conjoins `parent-document-permits?`, which short-circuits to `true` on a nil
`document_id`, so the copy was adjudicated by collection permissions alone
and disclosed, permanently and to every reader of the destination, material
the Document's content gate exists to withhold. `POST /api/card/:id/copy`
preserves the column and `create-card!`'s `data-keys` allowlist carries it,
so this tool was the only path that laundered the gate away.

Refuse a document-scoped source with a teaching error rather than carrying
the column: `copy-document!` re-parents such cards precisely because their
`collection_id` tracks the document rather than standing alone, so a copy
that kept `document_id` would claim membership in a document whose body
never references it. Neither outcome is the "standalone question in
collection-id" the caller asked for, so name the operation that works.

Also drop `check-allowed-to-create-card!` in favour of `api/create-check` +
`create-card!`, matching REST `/copy` and `clone-card!` (UXW-5037). Copying
is not authoring -- the query comes from a row the caller already passed a
read check on -- and the authoring stack refused a user who could read and
run a native card but lacked native query-building perms. Destination
create-permission is still enforced.

Tool description: drop the per-type create scopes GHY-4225 removed, stop
scoping the `uncopied` report to deep copies (`card->decision` discards
before it consults `deep-copy?`, so shallow copies drop cards too), and
state the document-scoped refusal.

`is_deep_copy` now rejects only an explicit `true` on a non-dashboard;
`false` is the documented default and the strict inputSchema forces clients
to send it. `readback` gets explicit ack-keys `[:type :uncopied_count]` so a
write-only token learns a copy was partial -- `:uncopied` itself must not
survive, since `redact` only redacts *unreadable* cards and an
archived-but-readable one keeps its real name.

Tests: pin the document-gated refusal through `mi/can-read?` as a user who
cannot read the parent document; pin the native-card copy; pin shallow-copy
`uncopied`; pin the degraded key set. Fix two existing tests -- the
"nothing was written" block counted the source collection the copy never
targets, and the destination-permission test asserted a bare `:isError`.

* Check run permission before inferring a card's result metadata

`question_write`'s update path ran the REST check stack before attaching
`:result_metadata`, so `check-update-result-metadata-data-perms` saw a patch
that did not contain the key and never fired. On an update carrying only
`column_metadata`, `check-allowed-to-modify-query` did not fire either — it is
gated on `:dataset_query` changing — so nothing checked data permissions at all
before inference ran over the stored query.

A caller with curate access to the card and no data permission on its query
could therefore annotate its columns, and the inference teaching error named the
query's real columns to them one guess at a time. The write succeeded, storing
the fully inferred metadata.

The create path never had this hole: `check-allowed-to-create-card!` runs the
run-permission check unconditionally before inference. This gives update the
same check, gated on the inference actually happening. It also covers what the
REST endpoint checks on a supplied `result_metadata`, because the overrides only
annotate columns the inference produced — they cannot name a table the query
does not.

`check-allowed-to-run-query!` is the pair `check-allowed-to-create-card!`
already inlined, extracted so both paths call the same thing.

Verified fails-when-broken: removing the call fails both new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2

* Derive protected-resource scopes from the resource path

The bare `/.well-known/oauth-protected-resource` endpoint advertised the v1
canonical resource with the v2 scope set: 6 scopes instead of 22. A client that
probes the bare path -- the behavior BOT-1617 added the endpoint to serve --
read 6, requested those 6, and received a token authorizing nothing on the
resource it had asked about. Empty `tools/list`, and no in-product way to widen
the grant afterwards. Worse, the wrong pairing became durable: the authorization
code grant copies the code's scope onto the access and refresh tokens without
re-consulting the client's registered ceiling, so recovery meant re-consenting
from scratch.

The cause was `protected-resource-metadata` taking the scope set as a separate
`scopes-fn` parameter. Three call sites passed a closure over their own path;
the fourth passed `mcp-resource-scopes` bare, which hit a 0-arity overload
hardcoding the v2 path. Both spellings type-check, and nothing at the call site
says "v2" -- the sibling accessor `mcp-canonical-path` returns the v1 path, so
the two read as interchangeable ways to name the MCP resource.

Rather than correct the fourth call site, drop the parameter: the function now
derives scopes from its own `resource-path`, so the resource and its scope set
cannot disagree. Slice 21 retires v1 and collapses this function; with the
parameter gone it cannot reintroduce the mismatch by overlooking an endpoint.

Delete the 0-arity overload, a delegating arity hiding a non-nil default. Its
only other caller was `default-grant-scopes`, where the union made it a provable
no-op -- `v2-scopes` is a subset of `all-scopes`, verified against a live
instance.

Also on the metadata/narrowing path:

- Delete the `:extra-scopes` plumbing. `registered-opt-in-scopes` read the key
  off every registered tool, but `register-tool!`'s closed allowlist rejects it,
  so the function provably returned the empty set. Since `register-tool!` runs
  at namespace load from a top-level `deftool`, and those namespaces load from
  the route table at boot, the first tool to use the documented key would have
  taken down the entire v2 MCP surface rather than just that tool. This also
  removes the only `mcp.core -> mcp.v2.registry` require.

- Narrow to the union of what every named resource accepts. RFC 8707 permits
  several resource indicators and the token must work against each, but the
  check asked only whether v2 was named *at all* and then applied that single
  answer to the whole request. Naming a v1 alias alongside v2 discarded every
  v1-only scope, order-independently -- contradicting the comment two lines
  above it and silently shrinking a grant the user then approved.

- Canonicalize resource URIs via `.getAuthority` rather than `.getHost`.
  `getHost` is nil for hosts `java.net.URI` considers non-conformant, notably
  any containing an underscore -- routine for Docker Compose and internal k8s
  service names, and accepted by Metabase's own `u/url?` as a Site URL. On such
  an instance no indicator ever matched and narrowing was disabled instance-wide
  and silently. Userinfo stripping, host case folding and default-port elision
  are preserved.

- Normalize a bare-string `resources` argument. The endpoint schema admits one,
  and `keep` over a String iterates characters, none of which canonicalize --
  which would skip narrowing entirely. Latent today only because oidc-provider
  vectorizes upstream; a security control should not depend on a coercion one
  layer away.

- Answer RFC 6749 `invalid_scope` when narrowing removes every requested scope.
  `narrow-scope-to-resource` returns nil both for "nothing was requested" and
  "nothing survived", and the call site treated them alike -- rendering a consent
  screen listing no permissions and minting a zero-scope token, a handshake that
  looks successful and yields an empty tool list with no diagnostic.

Tests: assert, for every protected-resource endpoint including the bare one,
that `scopes_supported` equals the scope set for the `:resource` the response
itself advertises -- one invariant covering all four, keyed off the response so
the two cannot drift. The previous tests could not catch this: the bare-path
test omitted `scopes_supported` entirely and the other asserted only
`sequential?`, both of which stay green with the feature deleted. Verified by
reintroducing the defect and confirming the new assertions go red.

Deferred as follow-ups: BOT-2124 (resource indicators are not enforced as a
token audience), BOT-2125 (v2 sub-paths escape narrowing under prefix routing),
BOT-2126 (observability when narrowing declines to apply).

* Make the v2 UI tools' handle resolvable by the iframe

`visualize_query` and `render_drill_through` return `{query_handle}`, but the
iframe bundle that ships on this branch reads `structuredContent.query` and
short-circuits on `if (query)`. With `query` absent the pending tool result was
never stored, `deserializedCard` stayed null, `isReady` never became true, and
the loading spinner never cleared. Both tools were non-functional.

The slice dropped three coordinated pieces that make the handle resolvable.
Ported from `mcp-v2-foundation`:

- `GET /api/embed-mcp/queries/:handle`, returning `{:query :prompt}` behind
  `check-session-header!` with an `ms/UUIDString` route param.
- `mcp-ui-parameterized-request-surface` + `on-mcp-ui-request-surface?`.
  `mcp-ui-request-surface` matches `[method uri]` by exact string and cannot
  express a path parameter, so without this the route 401s at the credential
  middleware before the router ever sees it. The regex is UUID-pinned and
  anchored with `re-matches`, keeping the entry as narrow as a literal one.
- `fetchQueryByHandle` plus a union `{query?, query_handle?, prompt?}` reader in
  `useMcpApp`, guarding out-of-order resolutions with `pendingQueryHandleRef`.
  One bundle serves v1 and v2, so both payload shapes stay supported.

Chose foundation's GET form over the POST variant on
`emb-2195-mcp-apps-are-stuck-on-loading-spinner-due-to-claude-sandbox`
(`79b792254cb`). The POST form needs no middleware change, but foundation's ships
the resolver, typed error messages, and the out-of-order guard already written.

`onAuthenticated` now receives the credential rather than reading it from state:
`setUiCredential` has not committed when the callback fires, so resolving from
the closure would use the previous (empty) credential and 401.

`queryError` is threaded to the route so a handle that cannot resolve renders an
error instead of spinning forever.

Docstring corrections. Three sites asserted the resolution endpoint existed in
the present tense; that is now true, and they name the route. The claim that the
store read is "keyed on `(mcp session, user)`" was false: `find-handle-row`
joins `core_session` and filters on `user_id` alone, and the looseness is
deliberate so a client rotating its MCP session keeps its handles. Verified live
against a running instance: cross-user resolution 404s, cross-session for the
same user 200s. The real native-SQL boundary is `check-mcp-ui-native-query!` on
`/api/dataset` via `+refuse-unscoped-native-sql`, which the file never named.
Fixed a dangling wikilink to a var that does not exist.

Dropped `:idempotentHint true` from `visualize_query`; the fresh-`query` path
inserts a handle row per call. `render_drill_through` keeps it.

Removed the `display` argument and output key. It was enum-validated and echoed
but no renderer read it, and `mcp_query_handle` has no column for it, so on the
documented preferred path (mint by `query`, re-visualize by `query_handle`) the
chart type was lost regardless. Rather than advertise a knob that does nothing,
it comes back end to end in GHY-4451.

Reverted the `all-scopes` rewrite. It was set-identical to the base
(`OLD-ONLY ()`, `NEW-ONLY ()`, 22 = 22) and deleted a correct sentence, while
the PR body claimed a scope-advertisement fix that never existed.

Tests. `visualize_test.clj` positively pinned the broken shape, asserting
`(= #{:query_handle} (set (keys body)))` — one side of a two-sided contract, and
green while the widget spun. It now asserts the absence of query data instead,
and two new tests walk the whole loop: mint a handle, call the tool, resolve the
returned handle over the credential-authenticated route. Deleting the endpoint
fails 9 tests; emptying the allowlist entry fails 6. The frontend spec covered
only v1's inline shape, so three tests now cover the handle path; two of them
fail against the previous hook.

Follow-ups filed: GHY-4450 (tools return success for handles the iframe will be
403'd on), GHY-4451 (restore `display` end to end), GHY-4452 (error-message,
schema and coverage cleanups), GHY-4453 (`prompt` dropped on the handle branch).

* Collapse an unreadable dashboard into the same not-found as a missing one

`resolve-dashboard!` read the row with no read check and threw the collapsed
not-found only when the row was absent. A dashboard that existed in a collection
the caller could not curate was refused later, by the write check, with a 403 —
distinguishable from the 404 a nonexistent id gave. A token scoped to one
collection could enumerate every dashboard id on the instance by watching one
against the other.

Resolving through `v2.resolve/resolve-and-read` gives the argument the read
check the rest of the module's id arguments have, so both cases read alike. The
write check still decides whether a card may be saved into the dashboard. This
also subsumes the hand-rolled existence check: `resolve-and-read` turns a
numeric id matching no row into the same not-found, which is what kept a bad id
from reaching the DB as a raw FK violation.

`update-move-into-dashboard-requires-destination-write-test` revoked every
permission on the destination, so under the read check it would now pass on
not-found and stop proving what it was written to prove — that the write
requirement on the move still runs. It now grants read and withholds write, so
the write check is again what refuses.

Verified fails-when-broken: restoring the unchecked read fails the new test.

Closes GHY-4443.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2

* Clear the five kondo warnings failing CI on this stack

`./bin/mage kondo` over the whole repo exits 2 on five warnings, so the
backend-tests / Clj-Kondo job fails. Both files are byte-identical to this
slice's parent commit -- the warnings arrive with earlier slices in the stack
and fail the same way there. Fixing here so slice 20 can go green.

- `browse_test.clj`: the `deep` collection is created for its side effect only,
  since the assertions look it up by name rather than through the binding.
  Renamed `_deep`.

- `question_test.clj`: `check-semantic-type!` is flagged as destructive inside a
  `^:parallel` test purely on the `!` suffix. It parses a keyword, throws on a
  bad one and returns it -- no state touched -- so the suffix means "may throw",
  not "mutates". Added to the `:parallel/safe` whitelist the linter's own
  message points at.

* Gate a native query_handle like the other two query sources

The `query_handle` source stored whatever query the handle resolved to without
consulting either SQL gate, so a token holding only `agent:content:write` could
save a card containing raw SQL, on create and on update, with the
`mcp-execute-sql-enabled` kill switch turned off. A later `run_saved_question`
then executes it — `execute_sql` rebuilt without its scope or its switch, which
is what `check-execute-sql-enabled!` exists to prevent.

The exemption rested on the premise that minting a handle already spent those
gates. It doesn't. `construct_native_query` mints under `agent:sql:construct`
and never consults the kill switch, and `/api/embed-mcp/drills` mints from an
uninspected caller-supplied string. Nor does the handle stay with the credential
that minted it: resolution joins `core_session` on `user_id` alone, the MCP
session id being recorded for telemetry, so any credential of that user can
spend a handle minted by any other. There is no independent handle TTL either —
handles outlive the request by the backing session's lifetime.

Gating on the resolved query's shape, as the inline `query` source already does,
covers all of it without knowing which argument carried the SQL. MBQL handles
are unaffected.

The docstring asserted the premise this commit refutes, so it now records why a
handle is not proof the gates were spent.

`create-question-via-native-query-handle-test` pinned the vulnerable behavior as
intended and is inverted. The update path had no native-handle coverage at all;
it gets both halves, plus the kill switch on the handle route.

Verified fails-when-broken: removing the guard fails 9 assertions across the two
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2

* Pin the invariants two advertised-scope tests only appeared to check

Both tests passed regardless of their input, so they read as coverage of the
advertised scope sets without providing any. Rewritten to assert on the sources
rather than on values the construction already guarantees, and verified by
injecting the fault each is meant to catch.

- `advertised-scopes-are-distinct-test` counted duplicates in the *output* of
  functions that build through a `sorted-set`, which is distinct by
  construction whatever goes in. It now checks every endpoint path and the v2
  surface literal, where a scope declared twice upstream is silently swallowed.
  Its docstring also still described unioning "the default grant with the opt-in
  scopes", which no longer exist.

- `default-grant-covers-everything-advertised-test` asserted that
  `default-grant-scopes` covers the sets it is defined as the union of. It now
  iterates `mcp-endpoint-paths`, which covers the per-path arities a client
  actually meets -- including the v1 aliases, inside the ceiling today only
  because `all-scopes` also feeds `supported-scopes`.

Add `mb-full-client-can-still-authorize-test`. Dropping `mb:full` from the
advertised sets is safe only because oidc-provider validates a requested scope
against the client's own registered `:scopes` and never against the provider's
`:scopes-supported`. Nothing pinned that, so un-advertising a scope would start
rejecting the first-party clients that legitimately hold it, silently.

Soften two docstrings that claimed more than the code delivers:

- `supported-scopes` implied explicit registration gates `mb:full`. Dynamic
  registration is unauthenticated and passes a client-supplied `scope` through
  unchecked, so omitting the scope here narrows who finds it, not who may ask.

- `narrow-scope-to-resource` read as though naming a resource bound the token to
  it. It shapes the consent screen and the stored grant only; `resolve-access-token`
  never reads `:resource`, and a client that omits the indicator is not narrowed
  at all. Both gaps are inherited; BOT-2124 tracks closing them.

* alert_write: close scope, error-message, and delivery-notice gaps

Six fixes from review of the slice.

Scope: clearing `send_once` turns a single scheduled run into an
unbounded series, the same commitment a new schedule makes, so
`execute-scope-trigger` now gates it on `agent:query:run`. Only an
explicit `false` counts — nested nulls survive the boundary's stripping,
so `send_once: null` stays an omission.

Errors: `fetch-alert` now checks write alongside read, collapsing
unreadable, unwritable, and nonexistent into the one not-found that
GHY-4217 needs. That lets the blanket 403 catch in `update!` go, so
every other 403 — a disallowed email domain, a channel template the
caller can't write — reports its real cause instead of claiming the
alert does not exist. `can-write?` mirrors `can-update?` for the bodies
this tool sends: it never reassigns `creator_id` and refuses `card_id`.

Delivery: `notify-creator-of-delivery-change!` becomes
`notify-caller-of-delivery!` and runs on create too. The notification
API's own "you were added" mail removes the current user, so an
agent-created alert pointed at a third party told the token's owner
nothing.

Subscriptions: `:subscriptions` is a `:multi-row?` nested spec, so
writing one back deletes the rest. Passing `schedule` to an alert with
several is now refused, mirroring the multi-channel handler guard.

Validation: the extracted `update-notification!` validates
`::NotificationApiUpdateInput` rather than relying on callers, which the
one non-endpoint caller did not do.

Reverts the `util/cron.clj` `(or hour 0)` change. `check-schedule!`
requires an hour for weekly and monthly, so it was unreachable here,
while the shared util's other callers — pulse sends, v1 create_alert,
database sync schedules — would have had stored null-hour rows move from
hourly to midnight with no test pinning either behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyVfcPqrmdSnV4YPXDMcUK

* Check the update's new state before probing the warehouse

`update!` ran `check-target-move!` — which opens a warehouse connection to
resolve `target-table-exists?` — before any permission check on the database
that probe connects to.

The tool's own `api/write-check` covers the transform as stored. The caller's
`definition` then names its own `:database`, and `resolve-source` runs no
permission check of its own, so `source-db` becomes any database id the caller
writes down; `resolve-target` stamps it onto the target, and the probe follows
it. The first and only authorization touching the new database lives inside
`transforms/update-transform!`, on the line after.

So a data analyst holding transforms permission on the transform's own
database — enough to pass `write-check` — could ask "does table X exist in
database B?" for a database they hold nothing on, one call per guess, and
drive outbound connections there. Two further gates were skipped along with
the permission check: `check-database-feature` rejects the audit database, the
sample database, and any database with DB routing enabled, and
`throw-if-db-routing-enabled!`'s own docstring says to call it before any
driver operation.

Run the new state's gates first, which is the ordering the create path already
states in a comment ("Before the target check, which opens a warehouse
connection — a bad argument shouldn't pay for that"). REST's `PUT
/api/transform/:id` has the same order and never connects.

The regression test asserts the ordering directly rather than the outcome: it
redefines `driver/table-exists?` to record whether it ran, since the tool
converts a throwing probe into a generic "Internal error" that an
outcome-based assertion passes on. Watched failing on `probed` with the two
forms swapped, and only that test fails.

Verified in a clean JVM on this branch: transform-test 23 tests / 83
assertions; transforms.crud-test + transforms.models.transform-test 10 tests /
41 assertions; kondo 0/0; cljfmt no-op; fix-modules-config unchanged.

* Normalize a retyped model's display, and let a model be a list

Retyping a question to a model left its `display` alone, where
`PUT /api/card/:id` forces table. A model's display is not inert: the query
processor runs the pivot QP when it is `:pivot`, so a pivot question retyped
through this tool kept returning pivoted data — REST makes that unreachable.
A chart display also drives subscription rendering, dashcard sizing, and the
search display filter.

The earlier review dismissed this as cosmetic because "the model layer renders
as table regardless." It doesn't: nothing normalizes a model's display anywhere
in the render path. The invariant holds only because the UI's own convert action
sets table, and REST's rule catches the rest.

REST leaves an explicit `list` alone, and writing that carve-out showed the tool
could not express `list` at all — so an agent could neither build a list-view
model nor preserve one it was updating, even though the model editor offers
exactly table and list. `list` joins the display enum, and the carve-out honors
it.

Retype-to-model had no MCP coverage at all before this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147UgqNGEjk9nV4jRnt9oS2

* Close the three pre-merge findings from the stack review, and the OSS test failure

Adversarial review over slices 02..21 raised 21 findings against `subscription_write`;
three were called pre-merge, and the PR review found a fourth that reddens the OSS job.
The rest are deferred to a follow-up issue.

**`update!` did its work before anything authorized it.** `mi/can-read?` on a non-alert
Pulse is `(or superuser? creator? recipient?)`, so being on the recipient list alone
passed `fetch-subscription`'s gate — with no permission on the dashboard or its
collection. Everything between that gate and `update-pulse-with-perm-checks!`'s
`write-check` then ran for such a caller: `resolve-parameters` answers an unknown id by
printing the dashboard's complete parameter-id set, `classify` probes user existence and
active status, and the channel-topology errors name the channel count and type. The
write check's own 403 also escaped uncaught, so a recipient could tell subscriptions they
are a target of from ids they know nothing about — the existence oracle the sibling
`alert_write` closes with a named GHY-4217 comment one commit below on this stack.

Gating `fetch-subscription` on `write-check` closes all four: the disclosures never run,
and `resolve-and-read-with` collapses the 403 into the same not-found everyone else gets.
Purely additive — `update-pulse-with-perm-checks!` runs `(api/write-check
(retrieve-pulse id))`, the identical check on the identical object, so no caller who
previously succeeded is now refused.

**A schedule field the schedule type never reads was dropped silently.** `check-schedule!`
enforced only the missing-required direction, so `{schedule_type: "hourly", schedule_hour:
9}` reported success and delivered the dashboard 24 times a day with `schedule_hour` NULL.
`alert_write` refuses the same shapes, and its docstring claims the two tools are kept in
step. Adds `check-ignored-schedule-fields!` over this tool's vocabulary (no
`:schedule_minute`), matching the writer exactly: `schedule_hour` for all but hourly,
`schedule_day` for weekly and monthly, `schedule_frame` for monthly. A filled-in null
stays an omission, and monthly's more specific `mid`/`schedule_day` error still wins.

**Repointing a Slack subscription erased the channel's other stored details.** The email
branch carries `(:details existing)` forward with a comment explaining why; the slack
branch replaced the column outright, so an edit dropped `include_pdf`/`attachment_only`
set in the app and the `channel_id` the sender prefers over the display name. The next
send dropped the PDF, and a channel rename sent it nowhere. Now merges onto the stored
details, and `slack-details` carries the cache entry's id as `:channel_id` so a repoint
overwrites the stale one rather than leaving it to misroute.

**The two EE-only parameter tests could not pass on OSS.** Every `define-premium-feature`
getter is `(and config/ee-available? (has-feature? ...))`, which `with-premium-features`
cannot flip, so both failed on the feature refusal while reporting the feature as set.
Wrapped in `mt/when-ee-evailable`, the pattern `redaction_test` already uses for this.

Each fix is pinned by a test that fails when the fix is reverted, verified by reverting
each one in turn: the write-check test's byte-identical baseline comparison catches a 403
that merely contains "not found", and `update-requires-write-permission-test` is tightened
from `(some? (tool-error ...))` — a tautology once it returns — to the collapsed message,
which is why this defect shipped green.

Verification: 44 tests, 116 assertions on EE and 113 on OSS (the guarded bodies compile
away), both fresh-JVM; `:module mcp` 565 tests / 6096 assertions; kondo and cljfmt clean.
Before this change the OSS run was 2 failures and 1 error.

* fix: add_action must write a virtual_card

The frontend recognizes an action dashcard through
`visualization_settings.virtual_card.display` (`isActionDashCard` in
frontend/src/metabase/actions/utils.ts). `add_action` hand-built its
visualization settings and omitted the wrapper every other virtual op
gets from `virtual-card-settings`, so an MCP-created action dashcard
rendered as an empty card instead of a button, and neither the
ActionSidebar nor DashCardActionsPanel recognized it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp

* fix: add_parameter requires a name

`add_parameter` reused `parameter-fields` wholesale, where `:name` is
optional because `update_parameter` patches properties individually. A
nameless parameter gets no slug, which half-breaks embedding, public
links and URL parameter sync, and `dashboard->resolved-params` requires
a non-blank name — so the tool errored on read-back *after* the write
had committed, leaving the parameter behind.

`:name` now lives on each op rather than in the shared field list:
required on add, optional on update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp

* fix: move_parameter refuses both index and dashcard_id

The op documents "pass exactly one of `index` or `dashcard_id`", but the
`cond` took the `dashcard_id` branch first, so a caller sending both got
the inline placement and no reorder with no error. `add_link` already
refuses the analogous both/neither case; this makes move_parameter
consistent with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp

* fix: move must not autoplace a dashcard against itself

`placement` took its siblings from the whole tab before the moved row
had been removed, so the card being moved counted as an obstacle to
itself and the search could never return the slot it already held. A
`move` carrying only a dashcard_id — which the schema allows, and which
reads as "leave it where it is" — always displaced the card: a
full-width size_y 4 card alone on a tab jumped from row 0 to row 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPHtStCj5NibqNCV4e1dpp

* Omit content_markdown when a document body has no Markdown form

get_content filled `content_markdown` with `prose-mirror/ast->text` output
when `documents/serialize` threw. That text is a flat space-join of text
nodes: no headings, no `{% card %}` embeds, no `:::` layout containers.

`content_markdown` is the key `document_write` accepts as a whole-body
rewrite, and both tool descriptions route a read-modify-write through it.
An agent reading a document with an unrenderable block, appending a line,
and writing it back replaced the stored AST with a single paragraph and
orphaned every anchored comment thread -- and the tool reported success.

`document_write`'s own `body-projection` faces the identical call and does
the opposite, with the rule in its docstring: "old_str is matched against
this exact text, so text that isn't the serialization is worse than none."
It emits `content_markdown_unavailable`. This mirrors it, including the
`Throwable` catch, so the two document paths agree.

Proven in a REPL against a body holding a heading, a `mysteryBlock` and a
card embed: `serialize` throws, `ast->text` yields "Q3 revenue regional
breakdown", and re-parsing that string returns 1 paragraph in place of 3
blocks -- card embed and heading gone.

Both tool descriptions corrected: `document_write` no longer tells the
agent to read with get_content and rewrite via content_markdown, and
get_content's round-trip promise now names the exception.

The existing fallback test asserted the degraded value was correct; it now
asserts the marker. Reverting the fix fails 5 assertions across the two
tests.

* Route the search tool's app-DB queries through mcp/db.clj

The Clj-Kondo check went red on the merge with three warnings, all in
tools/search.clj:

    Application database query calls like `t2/select` must live in
    metabase[-enterprise].<module>.db namespaces

The rule arrived with the base merge, and the module had already been routed
through metabase.mcp.db; search.clj was the last file still querying directly.

Its three reads move there as collections-for-read-check,
collection-id->name+location and snippets-by-archived-state, each a direct
Toucan 2 call with no added logic, per that namespace's own contract. The
column scoping the snippet query depends on travels with it: only
id/name/description plus collection_id for the read check, never :content,
which holds the SQL body. search.clj no longer requires toucan2.

snippet-rows-does-not-load-content-test stubs `t2/select` through with-redefs
to assert that column list. It still passes unchanged — with-redefs swaps the
var, and mcp.db resolves it at call time — so the guard survives the move
rather than needing to be rewritten around it.

Verified: targeted kondo 0/0 and a full `./bin/mage kondo` 0/0; cljfmt clean;
v2 search + v2 common + metabot search 81 tests / 329 assertions / 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CF3ZXRaq6wy8sFR9L8DPKa

* Fix four correctness defects in the query slice found by adversarial review

Keyset paging and the SQL hint each had a way to return the wrong rows, or no
rows, without saying so. All four are proven in a REPL against the Sample
Database, with a regression test each.

1. `with-total-order` and `next-page-query` dropped the query's `:parameters`.
   Both rebuild the query through `lib/prepare-for-serialization`, which strips
   `:parameters` as runtime-only — but on an MBQL query they are a real filter
   that the QP's parameter middleware ANDs on. `execute_query` runs every query
   through `with-total-order` before executing, so whenever the rewrite fired
   the QP ran the query unfiltered: a filter for quantity = 2 returned
   quantities [2 3 2 6 5], with no error and no truncation flag, and the minted
   handle described a different question than the one asked. `execute_sql`
   already worked around the identical strip; the MBQL path did not.

2. A caller could forge the private `::keyset` marker. The marker means "this
   namespace minted this predicate", and both `without-page-boundary` and
   `supersede-previous-keyset` strip clauses carrying it. But a tool's `:query`
   is an open [:map] and a namespaced option key rides through JSON decode and
   serialization intact, so a caller could stamp its own filter and have it
   silently dropped from the saved handle and from every later page — a narrow
   visible first page over a wide underlying read. Caller-supplied queries are
   now stripped where they enter. The cursor path deliberately keeps the marker:
   superseding the previous page's predicate depends on it, and stripping there
   stacks a dead filter per page instead.

3. `strip-sql-noise` threw StackOverflowError on an unbalanced quote. The
   literal-blanking pattern `'(?:[^']|'')*'` is an alternation under a
   quantifier, which java.util.regex compiles to one stack frame per character
   consumed. A single unterminated quote made it backtrack across the whole
   query. It runs on the `validate_only` path with no execution to gate it, and
   StackOverflowError is an Error, so it escaped the handler that turns tool
   failures into teaching errors and reached the transport uncaught. Replaced
   with the equivalent unrolled loop `'[^']*(?:''[^']*)*'`, verified to produce
   identical output on every literal shape and to handle 50k characters.

4. The cursor silently dropped every row whose key column was NULL. A keyset
   predicate is all comparisons, and under three-valued logic every term is NULL
   — never true — for a row whose key is NULL, so those rows were never served
   and the response still reported truncated: false. Ordering ORDERS by DISCOUNT
   served 1,915 of 18,760 rows and called the result complete. Nullable keys now
   refuse to mint, joining the remap and lossy-value guards; MBQL cannot express
   NULLS FIRST/LAST, so refusing is the only driver-independent answer.

   The refusal keys on declared nullability AND an observed `fingerprint nil%`
   above zero. Declared nullability alone is too blunt to gate on — in a typical
   warehouse only primary keys are NOT NULL, so it would have cost the cursor
   for nearly every non-PK-ordered query. The fingerprint is a sync-time sample,
   so a column that gains its first NULL after the last sync stays cursorable
   until the next one; that residual is the price of keeping paging usable.

`:module mcp` 658 tests / 6506 assertions green; kondo and cljfmt clean.

* Refuse a constraints value that names a column

`get_parameter_values` accepted `[:map-of :keyword :any]` for `constraints`, and
chain filtering compiles those values into MBQL. A value shaped like a clause was
therefore compiled as one: `{"_PRICE_": [["field", 49, nil]]}` became a reference
to that column, so the server evaluated `WHERE VENUES.PRICE = VENUES.CATEGORY_ID`
and returned the matching rows.

The caller needs only read access to a dashboard with two filters on one table.
It needs no query permission — `*param-values-query*` runs these lookups elevated
precisely so a caller who can only read a dashboard can still see its filter
values — and the reference reached columns marked `visibility_type: sensitive`,
which exist to be unreachable. A name-shaped reference works without a field id.

Reported by galdre on #81245 with a proof; his repro is the test.

`::constraint-value` now admits only scalars and sequences of scalars, so no
clause shape validates and nothing caller-supplied is ever compiled. Refusing at
the boundary rather than blacklisting known clause forms: a blacklist needs
updating every time MBQL grows a shape.

The ns docstring claimed no caller-supplied query material reaches the warehouse.
That is true again, but only because of this schema, so it now says so.

Verified by reproduction: before the fix both field-reference shapes returned
success where the test expects a refusal; after, all four attack shapes are
refused. The test keeps anti-vacuity controls — the unconstrained call returns
the full list and a literal constraint narrows it — so it cannot pass by failing
to reach the warehouse. Namespace: 86 assertions, 0 failures.

* Pin the constraints refusal against a caller who cannot query

The test I first wrote proved the schema rejects a clause-shaped constraint, but
ran under `with-fixtures`, which grants full data permissions. That misses the
point of the finding: the danger is not the shape, it is that a caller holding
NO query permission could read data through it.

Rebuilt on galdre's repro. The caller now has collection read and
`:perms/view-data :unrestricted` but `:perms/create-queries :no`, and the test
asserts up front that it cannot run a query against VENUES at all — so the
refusals below are about privilege, not validation tidiness. Adds his
sensitive-column case: a reference to a column marked `visibility_type:
sensitive`, which exists to be unreachable.

Verified by mutation: with the schema returned to `:any` this fails 3 assertions
(both field-reference shapes plus the sensitive column); the earlier weaker
version caught only 2. Anti-vacuity controls keep their teeth — the unconstrained
call returns all 100 values, a literal constraint narrows it, and a literal still
works after the fix, so the refusal is shape-based rather than blanket.

* Stop get_content handing a caller data-derived values it cannot reach

Two paths returned values computed from, or describing, rows and cards the
caller was denied. Both were reported in review of #81616.

`include: ["fields"]` returned stored column fingerprints verbatim. A
fingerprint is computed over the whole table: `:min` and `:max` are
individual cell values and `:distinct-count` counts every row. A caller
sandboxed to 10 of 100 rows received statistics over all 100, and a caller
with no data permission on the table at all received the same numbers.
Columns whose table is row-restricted now lose `:fingerprint`, via
`metabot.metadata-perms/row-restricted-table-ids` -- which covers
sandboxing, impersonation and database routing, and fails closed. Nothing
else in the payload is data-derived: names, types, semantic types and field
ids are schema, not rows.

`include: ["layout"]` returned `:visualization_settings` and
`:parameter_mappings` for dashcards whose card had just been redacted as
unreadable. `redact-dashcard` reduced the card to nothing, which is the
whole redaction for the projection, but `dashboard-layout` reads the same
dashcard directly -- so the caller still learned the card's author-written
title, the warehouse columns it renders with their labels, its click
targets, and the field ids its filters are wired to. The redaction now
strips those alongside the card. Layout keys (sizing, container styling)
describe the dashboard, not the card, and survive.

`:parameter_mappings` is emptied rather than removed: it is a required key
on a DashboardCard, and the parameters summary runs
`dashboard->resolved-params` over the same row, which fails its schema when
the key is absent.

The layout case is parity with REST rather than a regression --
`dashboards-rest/api.clj`'s `hide-unreadable-card` leaves the same settings
behind -- but this file already chose to be stricter than REST twice (the
link-entity strip in this very function, and the transform target-table
gate), so it follows that standard.

Tests: the fingerprint rule needs a live sandbox, so it lives in
`metabase-enterprise.mcp.v2.redaction-sandbox-test` alongside the other
sandbox-only redaction; it asserts an admin still sees fingerprints first,
so it cannot pass vacuously, and checks the table really is row-restricted
before trusting the result. The layout rule holds in OSS and is pinned in
`metabase.mcp.v2.redaction-test`. Reverting either fix fails its test.

`:module mcp` 5892 assertions, 0 failures.

* Cover archived reads and not-found parity beyond `question`

Two gaps Eric raised in review of #81616.

`get-content-reads-archived-content-test` pins the read-path behavior for
trashed content, which was projected but never exercised. Archived content
reads rather than 404ing -- an agent asked to restore something has to be
able to read it first -- and reports `archived: true` so it is not mistaken
for live. A live entity in the same test asserts the flag is not constant.

`get-content-not-found-parity-across-types-test` extends the existing oracle
check, which covered only `question`, to model, dashboard, document and
collection. Each type has its own fetch fn, so parity on one proves nothing
about the others; the test asserts the unreadable and nonexistent messages
differ only by the id.

Snippet is deliberately absent from that loop: snippets live in the
`snippets` collection namespace rather than under the restricted collection,
so `with-non-admin-groups-no-collection-perms` cannot make one unreadable.
Its parity needs a snippets-namespace setup of its own -- noted in the test.

Found while writing these, not fixed here: `:archived` is in the question,
collection and document projections but not the dashboard one, so a trashed
dashboard reads back with no archived flag. Filed to GHY-4471 rather than
widening this PR.

`content-test` 39 tests / 213 assertions, 0 failures.

* Name the scope get_parameter_values actually requires

scope-gate-test said the tool requires agent:resource:read. It declares
agent:content:read, and the test's own assertions say so — agent:sql:run is
refused, agent:content:read succeeds. agent:resource:read gates a resource
(catalog://metabase/fields), not this tool.

* fix(mcp): name budget-omitted tables from source rows

`budget-omitted` read `:id`/`:name` off the already-projected payload, but a
`fields` selection that excludes them leaves nothing to read: every omitted
entry serialized as `{"id": null, "name": null}`, so the caller could not tell
which tables were dropped and "request in a separate call" was unactionable.

`assemble-tables` no longer builds `:omitted` — its `:tables` is always a
prefix of `payloads`, which is index-aligned with `rows`, so `get-fields`
names the dropped tables from the source rows instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC

* fix(mcp): withhold fingerprints from row-restricted users

`get_fields`' detailed projection returned each field's `:fingerprint`, which
is computed at sync time across every row of the table. A user whose row
access is narrowed by sandboxing, connection impersonation, or database
routing got correctly narrowed `:values` but a whole-table fingerprint:
`:min`/`:max`/`:earliest`/`:latest` are literal values from rows they cannot
see, and `:global :distinct-count` is whole-table cardinality for every column
type. There is no safe subset, so the whole map is withheld, following
`metabot.tools.field-stats/field-statistics`.

The restriction lookup is batched over the request's tables and fails closed.
The `fields` projection path is covered by the same `detailed?` branch, so a
`fields: ["fields.fingerprint"]` selection cannot route around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC

* fix(mcp): require query permission to inline field values

`get_fields` gated only on `mi/can-read?` on the table, but then inlined
cached FieldValues, whose own check bottoms out in `can-read?` as well. A
caller holding `manage-table-metadata` alone therefore got values that
`/api/field/:id/values` refuses with a 403 — REST gates `summary` on
`read-check` and `values` on `query-check`, and that split has to hold here
too: values are rows of the table, not metadata about it.

`attach-inline-values` now primes the table perms cache once per request and
restricts the batched fetch to tables the caller can query. Tables it can only
read keep their full field metadata, without `:values`. The gate is per table,
so a mixed batch resolves each one on its own.

`response_format` now documents that values and fingerprints are
permission-dependent and absent rather than empty when withheld.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC

* test(mcp): drive browse_data through the registry

Every browse_data test called the handler directly, so the scope gate, the
closed args schema, and top-level nil-stripping went unexercised — unlike
browse_collection, which already dispatches through `registry/call-tool`.

Covers scope denial and grant (including tools/list visibility), the closed
map's teaching errors for unknown keys, wrong types, out-of-range limits and
bad enum values, the strict-client contract that an all-nulls call equals the
minimal one, the interaction between stripping and contains?-based per-action
validation, and nested nils being caught by the schema rather than reaching
the handler.

No behavior changes: every mechanism already worked. Each assertion group was
verified non-vacuous by perturbing it and confirming it goes red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC

* Build the sandbox test's card query with Lib

mt/mbql-query is deprecated since 0.61.0, and the :deprecated-var warning
fails the Clj-Kondo CI job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* Move get_content's app-db queries into metabase.mcp.db

The module-boundary linter requires t2 calls to live in a <module>.db
namespace, and its warnings fail the Clj-Kondo CI job.

table-by-id deliberately omits select-one-by-id's integer precondition: a
nil table_id would raise an AssertionError, which escapes the catch
Exception that degrades one definition to nil, failing the whole batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AFrL3LpdWBaQizx5QWaF5

* test(mcp): waive the mbql-query deprecation in the sandbox test ns

The full-repo kondo run reported three `#'metabase.test.data/mbql-query is
deprecated` warnings and exited non-zero. `:deprecated-var` needs
whole-project analysis to resolve the potemkin import, so the per-file lints
the file was checked with reported 0/0.

Uses the ns attr-map waiver rather than inline ignores: `:deprecated-var` is
budgeted at 86 in `.clj-kondo/ratchets.edn`, and three more would trade a red
lint for a red `kondo-ratchet-test`. The attr-map is counted by neither ratchet
and is the settled convention for this deprecation in EE test namespaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC

* [mcp-v2 12] definitions: adapt to the base's shared contracts

The merge from mcpv2-01-core was textually clean but semantically broken
in three ways, none of which appeared as a conflict.

- `v2.write/readback` dropped its 3-arity for a single 4-arity taking
  `ack-keys`. All four call sites passed three args, an ArityException at
  runtime. Now pass `nil` explicitly, matching metric/collection/question.
- `registry/call-tool` now answers `{:result …}` / `{:error …}` rather
  than bare MCP content. The test harness read the old shape, so every
  refusal looked like a success and every result read nil — 101 failures.
  `call-tool!` now normalizes both into the `{:isError true}` MCP shape,
  as `content_test/call-content` already does, leaving the helpers alone.
- `metabase.mcp.db` arrived with the `:metabase/t2-query-namespace` rule
  confining Toucan 2 calls to it. Both `t2/select-one :model/Table` calls
  become `mcp.db/table-by-id`, whose contract already covers the
  nil-tolerant column-value case these callers have.

Also: with the base's dynamic-require hook failing closed, `api-fn` trips
`:metabase/modules`. Added the sanctioned ignore and raised the budget to
24 — the dynamic resolve is deliberate, keeping `mcp` a consumer of the
segments/measures *models* rather than the *modules*.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbCo3ybRitdVSQXYcG97QR

* kick ci

* Pass ack-keys to readback in alert_write

readback takes [token-scopes read-scopes row ack-keys]. The call site passed
three arguments, which fails to compile against the integration branch.

nil matches every other _write tool: ack-keys exempts caller-supplied keys from
the read gate on the degraded path, and alert_write returns only server-derived
state.

* Move alert_write's app-db queries into mcp.db

recipients/classify called t2/exists? and check-writable! called t2/select-one
directly, which the module-boundary linter rejects outside metabase.mcp.db.

notification-by-payload-type already existed for the select-one.
active-user-exists? is new, alongside the existing subscription-pulse-exists?.

* Unwrap the call-tool envelope in alert_test

registry/call-tool returns {:result ...}/{:error ...}. The helpers read :isError
off the bare response, so every expected-error assertion saw a nil :isError and
reported "got success".

Same shape as bookmark_test.clj on the integration branch.

* Revert slice-17 fixes that rode in on the merge

The merge resolution fixed two pre-existing defects in slice 17's files to get
this branch's lint green: `v2.write/readback` called with 3 args where it takes
4 (alert.clj, subscription.clj), and six `:metabase/t2-query-namespace`
warnings. Both belong on `mcpv2-17-query`, and carrying them here guarantees a
conflict when 17 fixes them itself.

Restores the five files to `origin/mcpv2-17-query` byte-for-byte and drops the
four `mcp.db` fns that existed only to serve them. The six browse fns 16 needs
stay.

Lint is now red on this branch with 17's own 2 errors and 6 warnings, in files
identical to 17's. It goes green when 17 does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QkpS1yAdHikDjE1hQaShC

* Give readback its fourth arg, move Toucan calls into mcp.db, and teach the tests call-tool's new shape

Three defects surfaced while merging 17 into 16. 16 patched them locally to get
its own CI green; they belong here.

readback takes [token-scopes read-scopes row ack-keys], but alert_write and
subscription_write passed three, so both threw an ArityException on every create
and update. Ten of the other eleven call sites pass nil for ack-keys; only
bookmark passes a value, because bookmarked is caller-supplied and has to
survive scope degradation. Neither of these returns anything the caller handed
in, so nil is right.

The t2-query-namespace hook wants Toucan query calls in the module's db
namespace. notification-by-payload-type and subscription-pulse-exists? already
existed and already fit; nullable-fields-with-fingerprints, active-user-exists?,
dashboard-parameters and delete-query-handles-created-before! are new. query.clj
and recipients.clj no longer need toucan2.core at all.

call-tool now returns {:error {:code :message}} on a pre-dispatch rejection and
{:result mcp-content} once the handler has run, rather than MCP content
directly. Six test namespaces still read the old shape, which cost 135 failures
in CI: :content came back nil and :isError never fired, so a tool error could
pass as success. The helpers take the whole outcome now and share dispatch-error?
and response-text, mirroring browse_test on 16 so the two agree. scope-gating-test
asserts the denial arrives as {:error {:message ...}}, pinning the behavior change
instead of papering over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GJCEEY8W7fXmtMHUBS3Mc

* Fix subscription_write against the integration branch

readback takes four args; the call site passed three.

Two app-db queries moved into mcp.db, which the module-boundary linter requires:
subscription-pulse-exists? already existed, dashboard-parameters is new.

Test helpers unwrap the {:result ...}/{:error ...} envelope registry/call-tool
now returns, matching bookmark_test.clj.

* Drop the duplicate active-user-exists?

Slice 15 added one next to notification-by-payload-type; this branch already had
an identical definition. The merge kept both.

* Drop the duplicate active-user-exists?

Slice 15 added one next to notification-by-payload-type; this branch already had
an identical definition. The merge kept both.

* Unwrap the envelope in the surplus-schedule-field test

This case reads the response directly rather than through tool-error, so each
schedule type is asserted independently. It still read the pre-envelope shape,
so :isError was always nil and the refusal looked like a success.

* Fix dashboard_write against the integration branch

Four app-db queries moved into mcp.db, which the module-boundary linter requires:
dashboard.clj reuses the generic select-by-ids/select-one-by-id, subscription.clj
reuses subscription-pulse-exists? and a new dashboard-parameters.

Test helpers unwrap the {:result ...}/{:error ...} envelope registry/call-tool
now returns.

browse.clj still carries app-db calls here; slice 16 has already moved them and
merges first.

* Restore two mcp.db fns dropped in the integration merge

delete-query-handles-created-before! (called by the query-handle GC task) and
nullable-fields-with-fingerprints (called by v2/query.clj) are added by this
slice. Resolving db.clj wholesale toward the integration branch removed them,
so every namespace loading them failed with No such var.

* Stop import-mbql recursing forever on a numeric :source-card

content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.

The :source-table branch escapes only because it rewrites the value to card__N
and stops matching. Recur on the rest of the map instead, and set :source-card
from the resolved value.

* Stop import-mbql recursing forever on a numeric :source-card

content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.

The :source-table branch escapes only because it rewrites the value to card__N
and stops matching. Recur on the rest of the map instead, and set :source-card
from the resolved value.

* Stop import-mbql recursing forever on a numeric :source-card

content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.

* Stop import-mbql recursing forever on a numeric :source-card

content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.

* Stop import-mbql recursing forever on a numeric :source-card

content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.

* Stop import-mbql recursing forever on a numeric :source-card

content-ref? matches a portable entity_id or a bare numeric id. import-fk of a
numeric id returns a numeric id, so recurring on the whole match re-entered this
branch on its own output until the stack blew.

* Pass ack-keys to readback in dashboard_write

readback takes [token-scopes read-scopes row ack-keys]; this call site passed
three. nil matches every other _write tool: dashboard_write returns
server-derived entity state, so nothing is exempt from the read gate.

* Unwrap the call-tool envelope in duplicate_test

registry/call-tool returns {:result ...}/{:error ...}; the helpers read :isError
off the bare response, so a refusal read as a success.

* Unwrap the call-tool envelope in visualize_test

registry/call-tool returns {:result ...}/{:error ...}. The helpers read :isError
and :structuredContent off the bare response, so every error assertion saw nil
and the suite reported 10 failures / 14 errors against working code.

Adds dispatch-error?/response-text matching the other v2 tool suites, and
routes the two direct (:isError (call! ...)) reads through it.

* Migrate legacy MCP OAuth clients and tokens onto the v2 scope set

Slice 21 repoints /api/metabase-mcp at the v2 tool surface, which gates every
tool on one of six coarse scopes. Every MCP client connected to a shipped
x.60-x.63 release holds a token carrying the 17 legacy per-entity agent scopes
instead. No legacy scope satisfies any v2 tool scope and registry/list-tools
filters silently, so those clients handshake successfully and then get HTTP 200
with an empty tools list - no error, nothing logged.

It does not self-heal: the refresh grant copies the original scope forward and
can only narrow, and rotation issues a fresh refresh token each time, so a
refreshing client stays broken indefinitely. Manual re-auth fails too, because
/oauth/authorize validates the requested scopes against the client's registered
snapshot before narrowing, returning a 400 invalid_request rendered raw in the
user's browser tab.

Two data-only custom migrations fix both halves:

- WidenDynamicOAuthClientScopesForMcpV2 unions the six v2 scopes into every
  dynamically registered client's snapshot. This raises a ceiling, not a grant:
  no token gains anything, and the user still consents per-authorization.
- RevokeLegacyMcpOAuthTokens stamps revoked_at on access AND refresh tokens
  that satisfy no v2 tool scope and carry a scope outside the six. Both tables
  must be revoked or the client refreshes instead of re-authenticating.

Afterwards a client's next request gets the existing invalid_token 401 with the
discovery challenge, re-authenticates against a snapshot that now validates, and
lands with the six scopes - the RFC 6750 path every MCP client already handles.
A 403 insufficient_scope step-up was rejected deliberately: step-up is currently
broken in both Claude Code and Codex, so it would leave clients stuck.

The scope strings are literals rather than a read of mcp.paths/v2-surface-scopes
so the migrations' behaviour is frozen against later edits to that vector. Both
are idempotent and safe to re-run.

Tests: a row-level migration test over the changelog, an end-to-end transport
test that characterizes the empty-tool-list bug and then pins the fixed
behaviour, and an authorize test proving the 400 becomes a consent page.

* Re-apply the browsable-database filter to FK neighbour tables

related_tables filtered only on mi/can-read?, which evaluates per-table perms
against :db_id and has no notion of a router destination: a destination reads as
affirmatively readable because the Database read check redirects to its router.

An FK from a still-router table into a table whose :db_id was reassigned carried
the destination's column names into the response by the neighbour path, which
the directly-requested path already refuses. browsable-db-ids is already computed
for that path; thread it through.

* Make the destination-permission tests reach the create-check

Both tests asserting the destination create-permission refusal wrapped the
destination in `with-non-admin-groups-no-collection-perms`, which revokes
*read* as well as curate. `destination-collection-id` resolves the argument
through `resolve-collection-id`, whose documented behaviour is to collapse
"doesn't exist" and "exists but not readable" into one not-found error so the
argument never reports the existence of a collection the caller cannot see --
so the call was refused there, before `api/create-check` ever ran, and both
tests failed on the tightened `#"(?i)permission"` assertion introduced with
the `check-allowed-to-create-card!` removal.

Grant read on the destination so it is readable but not curatable, which is
the state the create-check exists to refuse. Matches the sibling case in
`collection_test.clj`, which grants read for the same reason and notes that a
not-found collapse there would mean the destination check had stopped running.

* Move app-db queries out of dashboards/write and pulse/broken-subscriptions

The app-db linter requires t2/... calls to live in <module>.db namespaces.
Slice 18's write.clj and broken_subscriptions.clj ran queries inline, which
Kondo flagged once this branch merged with mcpv2-01-core (where both the
linter and the .db namespaces live).

Each moved call is a direct Toucan 2 call in the module's .db namespace with
no added logic; call sites keep identical semantics. Reused existing helpers
where they already covered the query (dashboard, update-dashboard!, card,
card-queries, dashcards-by-ids, insert-dashboard!, insert-dashboard-tabs!).

Also regenerates .clj-kondo/config/modules/config.edn, restoring pulse and
query-permissions to dashboards-rest :uses along with the model imports the
generator derives.

* Ship only the scope-widening half of the MCP v2 OAuth migration

Widening a dynamic client's registered scope snapshot grants nothing: no token
gains a scope, and the user consents to the six on the new screen. It is safe to
run on upgrade.

Revoking the legacy-shaped tokens is the half that forces the re-authentication,
and it logs every connected MCP client out once. That is a release decision, not
a migration detail, so RevokeLegacyMcpOAuthTokens stays written and tested but is
not wired into a changeset. The test pins that no token is revoked yet, so wiring
it up later is a visible change rather than a silent one.

* Revert "Ship only the scope-widening half of the MCP v2 OAuth migration"

This reverts commit 88fec4366f.

* Pass token-scopes to issue-ui-credential in callback_api_test

This slice retires the 2-arity that minted claimless, gate-exempt :legacy
credentials. Four call sites here still used it and threw ArityException.

* Drop the duplicate check-semantic-type! kondo entry

Resolving .clj-kondo/config.edn by unioning both sides of the conflict added an
entry the file already carried further down, which made the EDN unparseable:
Duplicate key: metabase.mcp.v2.tools.question/check-semantic-type!

A broken config takes down Clj-Kondo and kondo-ratchet-test together.

* Take the integration branch's browse_test

Resolving this file's conflict earlier kept a comment from this branch, and with
it the whole pre-slice-16 file. assemble-tables no longer returns :omitted, so 6
failures and 23 errors followed. This branch adds no test the integration branch
lacks.

* Drop opt-in-scopes with the plumbing it called

Slice 20 removed registered-opt-in-scopes from the v2 registry; keeping this
wrapper left a call to a function that no longer exists, and nothing else
referenced it. The v2.registry require goes with it.

* Fix the last v1-retirement fallout: arity, unused require, module config

visualize_test still called the 2-arity issue-ui-credential this slice retires.

usage_test required metabase.mcp.v2.api without using it, and marked a test
^:parallel that calls register-tool! — which mutates the shared registry.

Retiring v1 removes namespaces, so the module boundaries moved; regenerated
.clj-kondo/config/modules/config.edn.

* Point usage_test at v2's behaviour on the retired v1 alias

The bare /api/mcp alias now serves v2. Three assertions still described v1:

- a direct call-tool result read :isError off the bare response, but v2 returns
  {:result ...}/{:error ...}
- an unknown tool is a registry rejection ({:error ...}) rather than a handler
  result carrying :isError
- DELETE tore the session down and stamped ended_at on v1; v2 answers 405, so
  nothing on that path ends the row

The DELETE case is a real gap, not just a test change: record-mcp-session-end!
is now callerless. Filed as GHY-4492 rather than wired up here, since choosing
where a v2 session ends is a design decision.

* Fix unregistered card-type schema keyword in check-card-can-be-saved!

`::queries.schema/card-type` was never registered; the schema is
`::card.type`. Malli threw `invalid-schema` at validation time, which
surfaced as an HTTP 500 where callers expected a 400/403.

The typo predates this merge, but nothing reached it: the REST card
endpoint still carried its own inline copy of this check, so the shared
fn had no live caller. Dropping that duplicate during the merge routed
the endpoint here and exposed it.

* GHY-4400 Give the MCP UI credential a purpose-built API (#82047)

* GHY-4400 Give the MCP UI credential a purpose-built API

`refresh_ui_credential` was a scope-escalation primitive. A token granting
only `agent:query:run` is refused `/api/user/current`, but the UI credential
minted from it was served the full profile — email, login and JWT attributes,
SSO source, group ids. Reproduced against the base commit; `/api/session/
properties` was worse, handing an admin's credential 400 settings keys against
140 for an anonymous caller, including SMTP, Slack, SAML, LDAP and embedding
secret configuration.

Both endpoints were reachable because `mcp-ui-request-surface` was an inventory
of what the embedded app happened to call rather than a decision about what the
credential should reach, and because the middleware stamped
`:token-scopes-checked true` unconditionally — which silently defeated the
`::scope/mcp-ui` stamp it sat next to. The comment claiming this "fails closed"
was wrong; it does now.

- Add `GET /api/embed-mcp/bootstrap`, returning the user and settings the
  iframe needs. The user projection is a closed schema, so widening it is an
  edit someone makes on purpose. Settings come back at non-admin visibility
  whoever holds the credential.
- Replace the allowlist with `[method uri] -> required scope`. Query routes
  and drills cost `agent:query:run` — the scope that already bought the iframe,
  since the v2 shell resources declare it. `:token-scopes-checked` is set only
  when the credential's signed claim covers the route, so a route added without
  a scope decision is refused rather than served.
- Drop `/api/user/current` and `/api/session/properties` from the surface, and
  seed the RTK caches from bootstrap instead, mirroring what the SSO path in
  `embedding-sdk-ee/auth/auth.ts` already does.

The credential still reaches endpoints its minting bearer cannot: all seven
declare no `:scope`, so `ensure-scopes-checked` 403s a scoped bearer on each.
That delta is what the credential is for; what it is not is a place for routes
to accumulate. `request-surface-is-a-reviewed-allowlist-test` pins the set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr

* Record why the seeded caches are safe, and how they could stop being

Verified rather than assumed, against RTK 2.12.0: neither `initiate()` on a
freshly upserted entry issues a request. The SSO path in
`embedding-sdk-ee/auth/auth.ts` runs the same sequence, but its endpoints stay
reachable, so a spurious refetch there would succeed silently — it corroborates
nothing.

The subscription those calls register is also the one way this design breaks. An
invalidation of `current-user` or `session-properties` refetches, and both now
fail — the second one fails open, answering anonymously with public-only
settings rather than erroring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr

* Build the scope-gate test's query without a deprecated macro

`mt/mbql-query` has been deprecated since 0.61.0, and `mage kondo` exits
non-zero on warnings, so the one call reddened CI. Per-file linting cannot see
it: `:deprecated-var` needs the defining namespace's analysis cache, so the file
reads clean in isolation and only the whole-tree run reports it.

The test needs nothing from that macro beyond a valid non-native query, so
spelling out the map it expands to keeps the scope gate as the only thing
separating the two statuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr

* Test the MCP iframe against the bootstrap projection, not a full user

Nothing on the frontend would have failed if the projection dropped a field the
render tree needs. The hook spec mocked the API module and seeded `{id: 1}`, so
it stayed green whatever the payload became, and the one MCP spec that mounts a
real `SdkQuestion` seeds a full `createMockUser()`.

- `createMockMcpAppsBootstrapUser` is an object literal typed as
  `McpAppsBootstrapUser` rather than cast, so the build breaks if the type loses
  a field.
- `createMockMcpAppsBootstrapSettings` picks the keys the backend's
  `bootstrap-setting-visibilities` actually allows, derived from the setting
  registry rather than guessed. The existing SDK state helpers re-expand
  whatever they are given through `createMockSettings`, which would put the
  admin-only keys back and make the exercise vacuous.
- `McpQuestionView.unit.spec.tsx` mounts the real question view with the store
  seeded only from those fixtures, and asserts a chart actually renders.

What this does and does not catch, measured rather than assumed. Removing
`token-features` from the seeded settings turns the new spec red. Removing
`locale`, `permissions` or `is_superuser` from the user does NOT: every
consumer on the reachable path reads them optionally and defaults, so nothing
observable changes. Those three have compile-time coverage only. Runtime
coverage for them needs assertions on behavior they drive — a non-`en` locale
changing rendered text, a drill menu gated on `can_create_queries` — which is a
different test than this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr

* Stop asserting the UI credential reaches /api/user/current

v1's copy of the surface test still expected the pre-GHY-4400 behavior. It is
the twin of the v2 test already updated on this branch, and the only remaining
place asserting the old contract.

Its credential is minted through v1's `refresh_ui_credential`, so it is
`:legacy` — which makes this the one test pinning what a legacy credential can
still reach, coverage lost when `bootstrap-accepts-ui-credential-test` moved to
the scoped arity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019r4TtdH2RQy1eGbQ5odnjr

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Sort the malli.util require in collections.db

Clj-Kondo and Cljfmt were both red on this file: kondo reported
`Unsorted namespace: malli.util`, cljfmt reported incorrect formatting. Same
defect from two angles — `c8d1ef7b47c`'s merge resolution placed the branch's
`[metabase.app-db.core]` above master's `[malli.util]`.

Full-tree kondo is clean afterwards, and no other file carries the same
problem.

This does not address the rest of that merge: `collections_rest/api.clj` still
has master's `/root/items` refactor reverted, which is what is failing ~164
backend assertions and 20+ e2e groups.

* Finish master's kebab-case rename on /root/items (GHY-4495)

Master's #79863 renamed the collection-items query params from snake_case to
kebab-case — `pinned_state` -> `pinned-state`, `include_library` ->
`include-library`, and the rest — updating the frontend and tests with it. Merge
`c8d1ef7b47c` applied that rename only PARTIALLY: `/root/items` kept the old
names while its own sibling `/root/items/metadata` had already been renamed, and
the test file was left similarly half-converted.

The endpoint schemas are `:closed true`, so an unrecognised param is ignored
rather than refused. Every filter silently stopped applying: `pinned-state=
is_not_pinned` returned pinned items, `include-available-models=true` returned
no `available_models`, and `collection-type` did not filter. The frontend was
already sending kebab-case, which is why this broke collection browsing broadly
rather than in one place.

Completing the rename is the whole fix:
- collections_rest/api.clj — the 17 occurrences in `/root/items`
- collections_rest/api_test.clj — 7 call sites the merge left snake_case
- collections/children.clj — docstrings still naming the old spelling

collections-rest: 186 tests, 950 assertions, 0 failures / 0 errors.
documents.api.collection-test: 0 failures. Full-tree kondo 0/0, cljfmt clean.
No remaining snake_case call sites anywhere in src/test/enterprise/e2e/frontend.

Note for GHY-4495: master's `children_query.clj` did NOT need restoring and
`collections/children.clj` did NOT need deleting. The branch's extraction (slice
16, for `browse_collection`) is fine as it stands; the failure was the rename,
not the refactor.

* Read ProseMirror node attrs by string key (GHY-4496)

Master changed ProseMirror node `attrs` from keyword keys to string keys and
made it a schema-enforced invariant: `::node.attrs` is a `string-keyed-object`
whose docstring states "every key, declared or not, is a string, so the map
never mixes keyword and string keys". The `mcpv2-01-core` merge (c8d1ef7b47)
took master's string-keyed `prose_mirror.clj` but kept the branch's
keyword-keyed consumers, so every read silently missed.

Silent, not loud: these produce wrong values rather than exceptions, and CI
was quiet about all of it.

- `update-cards-in-ast` never matched, so a card created inside a document kept
  its negative placeholder id instead of being rewritten to the real one.
- `markdown/serialize` dropped a code block's language and rendered every
  heading as h1, because `(:level attrs)` / `(:language attrs)` read nil off a
  stored AST and fell through to their defaults.
- `changed_blocks` came back empty from document_write, since block identity is
  keyed on `_id`.
- `get_content` reported `card_id` nil for every card embed, and comment
  anchoring resolved no ancestors.

Every AST reaching this code from the app db is string-keyed: `:model/Document`
runs `prose-mirror/normalize-document` as an `:out` transform, and
`string-keyed-object` stringifies attrs keys on decode. Verified by round-tripping
a real Document row -- wrote `{:level 2}`, read back `{"level" 2}`.

Two sites are deliberately asymmetric: `markdown.clj`'s `segment->nodes` and
`container-node` read their own keyword-keyed parse structures and emit
string-keyed prose-mirror nodes. Converting both sides there would have broken
the parse path.

The test files hand hand-written ASTs straight to `parse`/`serialize`, bypassing
normalization, so their fixtures encoded a shape production never produces.
Converting them turned up two further defects in this commit that the source
sweep alone missed: the smartLink fallback serialized an empty label, and
`ast-id->type`/`changed-blocks` still read `:_id`.

Behaviour is unchanged wherever it was already correct -- the diff is 209
insertions against 209 deletions, all key style.

Verified: metabase.documents.markdown-test (352 assertions), plus
prose-mirror-test, search-test, mcp.v2.tools.content-test,
mcp.v2.tools.document-test, comments.render-test, search.ingestion-test --
864 assertions, 0 failures. `metabase.documents.api.document-test` improves
from 58 failures to 9; the remaining 9 (`copy-document-test`) fail identically
on the unpatched branch and are unrelated to attrs.

kondo 0 errors 0 warnings; cljfmt clean.

* Point McpQuestionView's mock import at __support__/state (GHY-4497)

The merge (c8d1ef7b47) left this spec importing `metabase/redux/store/mocks`,
a path master no longer has, failing `frontend-tests / fe-type-check` with
TS2307. Both `createMockEmbedState` and `createMockState` live under
`__support__/state` -- the barrel re-exports `./embed` and `./state` -- which is
where the rest of the tree imports them from (see
`embedding-sdk-bundle/test/server-mocks/sdk-init.ts`).

`bun run type-check-pure` goes from 66 errors to 65: this one, and nothing else
introduced. The other 65 are a missing ClojureScript build in a fresh worktree
(`cljs/*` TS2307) plus pre-existing @types/node mismatches (`Timeout` vs
`number`, `ImportMeta.dirname`), all present before this change and none in
files it touches.

The audit's second item for this ticket -- regenerating
`.clj-kondo/config/modules/config.edn` -- turned out to be a no-op:
`./bin/mage fix-modules-config` reports "config.edn already up to date", and
`metabase.core.modules-test` passes with 4492 assertions. GHY-4495 restored the
`metabase.pulse` `:api` entry the audit found dropped, so the drift was gone by
the time the ordering allowed this to run.

* Declare the MCP keyset marker in the clause options schema

MCP v2's keyset paging stamps a private marker on the filter clauses it mints,
so the next page can supersede its predecessor instead of stacking a dead
predicate beside it. Master's #82196 made `lib.schema.common/::options`
`:closed true` and enumerated every namespaced option key the platform stamps —
Mongo's `join-local`, SQL Server's `optimized-bucketing?`, the add-alias-info and
add-remaps keys. It could not have included this one: MCP v2 lives on this
branch.

So `lib/filter` began rejecting the stamped clause:

    Invalid input: [nil #:metabase.mcp.v2.query{:keyset ["disallowed key, got: true"]}]

`next-page-cursor!` treats any failure to build a cursor as "this query cannot be
paged" — correct, since refusing to page is always safe — so the rejection
surfaced as cursors silently never being minted rather than as an error. That is
why it read as a guard refusal: `metabase.mcp.v2.query-test` failed 1 assertion
and errored 8 more with "Query handle not found", all downstream of a nil cursor.

Bisected: passes at `eea6bb9f914` (slice 17's own merge) and `a8024807cbc`,
fails at `c8d1ef7b47c` (the master merge). The merge changed none of
`mcp/v2/query.clj`, `mcp/v2/queries.clj` or `agent_api/query_guards.clj` — it
broke them through Lib.

Registering the key is the step #82196 performed for every other option-stamping
module, not a workaround. `{:optional true}`, so no existing query is affected.
`mcp/v2/query.clj`'s logic is unchanged: the GHY-4142 refusal contract — no
cursor unless gap-free paging is provable — is intact.

Also here:
- `internal-keys-fixture-covers-the-schemas-test` derives the declared key set
  from the schema and requires a fixture entry for each, so it broke by design
  until the fixture gained one. Adding it also gets the marker covered by the two
  stripping tests, which is worth having.
- The `keyset-marker` docstring said the marker "rides in a clause's options map,
  which no schema declares" and that undeclared keys would merely be stripped.
  Both are now false — they are rejected — so it says what actually holds.

metabase.mcp.v2.query-test 62 assertions, metabase.lib.serialize-test 31, both
0 failures / 0 errors. Full-tree kondo 0/0. `:module query-processor`'s 8
failures are pre-existing, reproduced at a clean checkout of the same HEAD.

* GHY-4498 Accept a numeric id sent as a JSON string (#82294)

Accept a numeric id sent as a JSON string (GHY-4498)

Claude Desktop serializes every value of an `anyOf [integer, string]` param
as a string, so `run_saved_question` received `id: "16211"` and rejected it —
while the advertised schema's string branch says that value is valid. The
model cannot force the JSON type, so the numeric id was unreachable from
those clients and only the entity_id worked.

`normalize-id` coerces strings matching `^[1-9][0-9]*$` to integers, at three
chokepoints that between them cover every id param: `resolve-id-or-404` (the
read tools, and every collection_id/parent_id through `resolve-collection-id`),
`dispatch-write` (the `id` of all ten `_write` tools), and `get_content`'s
per-item fetch, which needs its own because alerts and subscriptions have no
entity_id column and reject a non-integer id before any resolution runs.

The `"root"`/`"trash"` sentinels are matched before coercion, and digit runs
too large for a long stay strings and fail validation like any other non-id.
Schemas are unchanged: the string branch already admitted these values.

The teaching error now names a retry that works — reading "pass a numeric id"
the obvious next move is to resend the same number, which fails identically.


Claude-Session: https://claude.ai/code/session_01EWoDYrBS4e4dAWaDeWLpVV

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Send alert_write's update only the fields the write schema accepts

Master's merge brought a new `notification/schema.clj` and closed every
notification schema, including the `hydrated-notification-schema` behind
`::NotificationApiUpdateInput`. `alert_write`'s update path was passing back the
whole hydrated notification from `get-notification`:

    (notification.api/update-notification! id (assoc existing :payload payload))

An open map tolerated the read-only hydration keys. A closed one rejects them:

    :creator ["disallowed key"], :id, :created_at, :updated_at
    :payload {:id, :created_at, :updated_at, :disable_links, :card ["disallowed key"]}

`->mcp-error-content` sanitizes any non-caller-facing exception to a bare
"Internal error" and logs the original at a level the test runner suppresses, so
every alert_write update — including simply pausing an alert with
`{:active false}` — failed with no usable message. 7 failures / 24 errors.

The body is now built from the write shape rather than trimmed down from the read
shape, so a future hydration key cannot reintroduce this. `:subscriptions` and
`:handlers` still carry their existing rows when untouched, because the update
spec deletes whatever a body omits.

Bisected: passes at `a8024807cbc`, fails at `c8d1ef7b47c` (the master merge).

metabase.mcp.v2.tools.alert-test: 35 tests, 141 assertions, 0 failures / 0 errors.
kondo 0/0, cljfmt clean.

Not fixed here: `mcp.v2.tools.dashboard-test`'s 2 failures / 4 errors are a
DIFFERENT cause and unchanged by this. `metabase.dashboards.db/update-dashboard!`
types its `changes` as `(mut/select-keys ::dashboards.schema/dashboard.update
[:parameters])`, so any update touching `:description` or `:cache_ttl` is refused
— a dashboards-module bug, not a notification one.

* Say in bookmark_content's description that nothing reads a bookmark back

Nine of ten fleet workers, told to confirm a bookmark they had just written,
spent three or more calls establishing that no read path exists: grepping all
22 tool schemas for "bookmark" (it appears only inside `bookmark_content`),
checking `get_content`'s `include` enum, checking `search` for a `bookmarks:`
mode symmetric with its existing `recent:`, and reading the generated fields
catalog. Each concluded correctly that the write's own response is the only
report of bookmark state, but only after spending the calls to prove a
negative.

The absence is a real gap, not a defect to paper over: `search` and
`get_content` would each need the row hydrated with a per-user bookmark flag,
and `metabase.mcp.v2.projections` is by contract pure -- "no app-DB access and
no read of the current user" -- so a `bookmarked` field belongs in the fetch
layer, not the projection. That is a larger change than this one.

What is cheap now is telling the agent, so it stops paying to discover it.

* Say in search's `recent` description that it tracks UI views only

Five of ten fleet workers read content with `get_content`, then called
`search(recent: true)` expecting to see it, and got an empty list. Several
could not tell "these reads are not recorded" from "the recents index has not
caught up yet", and spent a call each establishing which.

Recents come from the view log, which the REST read paths write and the MCP
tools do not. That is the existing behaviour and this commit does not change
it -- an agent's tool calls are arguably not "views" in the sense the sidebar
means. It was simply never written down where the agent would look.

* Match master's response schema on the document copy endpoint

`POST /api/document/:id/copy` was 400ing on its own response:

    "Invalid response" {:creator ["should be spelled :creator_id, received: #Instance :model/User ..."]}

The endpoint declared `:- ::documents.schema/document` and returns
`copy-document!`'s result, which is `get-document` — hydrated with `:creator`,
`:can_write`, `:can_delete`, `:can_restore`, `:is_remote_synced`. That was fine
while `::document` was open. The master merge brought its closed version, which
describes the app-DB row only ("every column of `:document`"), so a hydrated
document no longer satisfies it.

Master's own copy endpoint declares `:- [:map [:id ::documents.schema/document.id]]`
— an OPEN map that asserts an id is present and permits the rest. Its test asserts
on `:name`, `:collection_id` and the document AST, so master returns the whole
document too; the schema is permissive rather than descriptive. Adopting that line
verbatim leaves our declaration identical to master's, so there is nothing to
reconcile at the eventual merge.

Considered and rejected: adding a `::document.hydrated` schema. It worked, but put
new surface in a shared module to solve a branch-local problem, and diverged from
master where matching it costs nothing.

metabase.documents.api.document-test: 100 tests, 546 assertions, 0 failures /
0 errors — was 9 failures / 1 error. kondo 0/0, cljfmt clean.

* Let update-dashboard! take the schema its name promises

`dashboards.db/update-dashboard!` typed its `changes` as
`(mut/select-keys ::dashboards.schema/dashboard.update [:parameters])`, so every
attribute except `:parameters` was refused:

    Invalid input: {:description ["disallowed key, got: nil"],
                    :cache_ttl   ["disallowed key, got: nil"]}

`::dashboard.update` describes itself as "what an update (or insert) of a
Dashboard accepts", and `insert-dashboard!` directly above takes the whole thing.
Update accepting strictly fewer fields than insert was the anomaly; the narrowing
fit when the only caller wrote nothing else.

This branch added such a caller. `metabase.dashboards.write/update-dashboard!`
(new in `c0ddd5fcd4a`, GHY-4147 — the file does not exist on master) selects
`:name`, `:description`, `:archived`, `:collection_id`, `:cache_ttl`, `:width`,
`:position` and more before delegating here, so every dashboard_write touching an
attribute failed. Not merge damage: `dashboards/db.clj` is identical to master,
and the incompatibility is our new caller meeting master's narrower type.

mcp.v2.tools.dashboard-test: 2 failures / 4 errors -> 1 failure / 0 errors.
:module dashboards 114 assertions, 0 failures. :module dashboards-rest 1020
assertions with one pre-existing failure —
`dashboard-card-query-export-format-no-self-service-test`, an EE
download-permissions 403 — verified by running it with this change reverted.
kondo 0/0, cljfmt clean.

The remaining dashboard-test failure is GHY-4500, a separate bug: our copy of
`check-parameter-mapping-permissions` in `dashboards/write.clj` kept the narrow
`::parameter-mapping` schema that #82196 widened on the REST side, while still
assoc'ing `:dashcard-id`/`:card-id` onto every mapping.

* Declare the mapping keys the permission check is handed (GHY-4500)

Every `dashboard_write` that added or changed a parameter mapping —
`wire_parameter` by `target_tag`, `target_field` or raw `target`, and
`patch_dashcard` with `parameter_mappings` — returned "Internal error". Filters
could not be built through the MCP at all. `validate_only: true` succeeded on the
same ops, since it never reaches the check.

#82196 closed `::parameters.schema/parameter-mapping` to
`{parameter_id, target, card_id}`, and widened the REST copy of
`check-parameter-mapping-permissions` to also allow `:dashcard-id`/`:card-id`.
This branch has a second copy of that function in `dashboards/write.clj` — a file
that does not exist on master — which kept the narrow schema while lines 167/174/
183 still assoc those two keys onto every mapping before calling it:

    Invalid input: [{:dashcard-id ["disallowed key, got: 177"],
                     :card-id     ["disallowed key, got: 166"]}]

`mu/defn` throws `::invalid-input`, which carries no `:status-code`, so the MCP
sanitizer reported it as "Internal error" — see GHY-4502.

The schema now matches the REST copy verbatim, since this function is a fork of
it and the two drifting is what caused this.

Dev and test only: `mu.fn/instrument-ns?` skips prod, so schema checks do not run
there. That still covers every developer machine and CI, and the frontend was
never affected because it goes through the REST copy.

mcp.v2.tools.dashboard-test: 26 tests, 65 assertions, 0 failures / 0 errors — was
1 failure. :module dashboards green. kondo 0/0, cljfmt clean.

* Say in search's description that an empty result can be a cold index

A fleet worker searched five times for content that provably existed and got
`{"data":[],"returned":0,"total":0}` every time. Its instance's active search
index held one row where healthy peers held sixty-six -- indexing had not
finished when the worker started. Nothing in the response distinguished that
from a genuine no-match, so the worker concluded the content was absent and
abandoned search for manual browsing, turning a two-call job into about eleven.

The honest fix is for the envelope to carry the index's status, but nothing
readiness-related is exported from `metabase.search.core` today, so the tool
cannot ask. Until it can, this at least tells the agent that an empty result
has two causes and names the tools that do not depend on the index.

* Advertise the v2 scope set on every MCP path now that v1 is retired

`mcp-resource-scopes` branched on the path: `/api/metabase-mcp/v2` got the six
scopes the v2 tool registry gates on, and the other aliases got
`all-scopes` — twenty, including the fourteen per-entity agent-API scopes
(`agent:question:create`, `agent:sql:execute`, …) that v1's tools gated on.
That was right while those paths still served v1: narrowing them to the v2 set
would have told a client to request scopes no v1 tool accepted, leaving it with
an empty `tools/list`.

Retiring v1 (#81708) pointed every path at the same v2 handler and inverted the
bug. The canonical `/api/metabase-mcp` — the path the docs advertise, the one
`.well-known/oauth-protected-resource` names, and the one clients probe — served
v2 tools while advertising fourteen scopes nothing gates on, putting them on a
consent screen for capabilities that no longer exist. The old docstring
anticipated this and said the fn "collapses to the v2 answer" when v1 retires;
this is that collapse.

    /api/metabase-mcp       20 scopes -> 6
    /api/mcp                20 scopes -> 6
    /api/metabase-mcp/v2     6 scopes -> 6   (unchanged)

`path` stays in the signature: RFC 9728 metadata is per-resource, and a future
surface may diverge again.

The tests changed because they pinned the branch itself — "a v1 alias keeps the
scopes v1's tools gate on", and an assertion literally computing
`(= (not= path "/api/metabase-mcp/v2") …)`. They now assert every path answers
the same set, which is the property that will keep holding.

No user-facing path changes: `docs/ai/mcp.md` and `llms-install.md` already
advertise `/api/metabase-mcp`, and `/api/metabase-mcp/v2` stays routed for
client configs written while the surface lived there.

Verified: 726 assertions across oauth-server.core-test, oauth-server.api-test,
mcp.v2.api-test and mcp.transport-test, 0 failures. kondo 0/0, cljfmt clean.

* Name the function when a server-side schema check fails (GHY-4502)

Any exception without a 4xx `:status-code` or an explicit MCP error code became
the two words "Internal error". The real exception was logged server-side, which
an agent cannot read and which on a dev box goes to somebody else's terminal.

That cost real time three times over on this branch alone — alert_write's
hydrated body, dashboard_write's parameter mappings, and the document copy
endpoint were all `mu/defn` schema failures reported as "Internal error", each
needing instrumentation past the sanitizer to see. The ticket records an agent
spending nine failing calls over two turns retrying variations, learning nothing,
because every answer was the same two words.

A `mu/defn` failure now names the function:

    Server-side schema check failed in `check-parameter-mapping-permissions`:
    [{:dashcard-id ["disallowed key, got: 177"]}]. This is a bug in Metabase, not
    something to retry — report it.

The message says it is a bug rather than a caller mistake, so the agent reports
it instead of retrying variations of a call that cannot work.

Deliberately narrower than the ticket asked, on one point. The humanization is
included only for `::invalid-input`, where it describes the caller's OWN argument
echoed back. `me/humanize` embeds offending values ("disallowed key, got: 177"),
so for `::invalid-output` — which describes SERVER-produced data — echoing it
could hand the caller a row they have no right to read. Those name the function
and stop. `:value` is never touched in either case: it holds the whole argument
or return value.

Everything else still redacts: the existing SQL, JDBC and NPE cases in
`error-redaction-test` are unchanged.

The correlation-id half of the ticket is not done here.

mcp.v2.common-test: 13 tests, 91 assertions, 0 failures. :module mcp 3487
assertions with one pre-existing error in `query-test`
(`unknown-caller-key-never-reaches-the-qp-test`, a query-validation teaching
error) — verified by running it with this change reverted. kondo 0/0.

* Roll back the dashboard row when a create's second save fails (GHY-4501)

`dashboard_write` `create` with ops writes the dashboard row, then applies the
ops in a second save. When that second save threw, the row stayed. The tool's own
description tells the agent to "find it by name and finish it with update", so in
the field the agent read the error, called `create` again, and again — leaving a
pile of empty dashboards.

There was already a mitigation: the ops are compiled against a blank dashboard
before anything is written, so a bad OP cannot orphan a row
(`create-with-a-bad-op-writes-nothing-test`). It cannot catch a failure that is
not in the ops. The reported case was a `wire_parameter` create failing inside
`check-parameter-mapping-permissions` — a schema rejection the dry run never
reaches, because it does not perform the permission check.

The two writes now share a transaction, so anything throwing after the row exists
takes the row with it, whatever the reason.

Fixing GHY-4500 removed the reported trigger, but not the window: any post-create
failure had the same effect. The new test forces one directly rather than relying
on a bug to produce it, so the guarantee is pinned independently of whatever
happens to throw next.

mcp.v2.tools.dashboard-test: 27 tests, 67 assertions, 0 failures / 0 errors.
mcp.v2.dashboard-ops-test green. kondo 0/0, cljfmt clean.

* Retire the /api/metabase-mcp/v2 alias everywhere

`/api/metabase-mcp/v2` is where the v2 surface lived while it was behind a flag.
The switchover repointed the canonical path and `/api/mcp` at it, so the alias
had nothing left to do.

Removing it from `endpoint-paths` alone was not safe: the path kept serving
traffic while dropping out of everything derived from that set. In particular
`narrow-scope-to-resources` would no longer match an RFC 8707 indicator naming
it, and that path fails OPEN — an unmatched indicator narrows nothing, so a
client naming `/v2` would have kept `mb:full` where it previously lost it.

Carried through all five places:

- `mcp/paths.clj` — out of `endpoint-paths`; the docstring now names the three
  things derived from the set and says what a drifted path costs.
- `mcp/core.clj` — `mcp-v2-path` deleted. It still read `mcp.paths/v2-path`,
  which was already gone, so `metabase.mcp.core` would not load at all.
- `api_routes/routes.clj` — the `/v2` sub-route dropped; `/metabase-mcp` is now
  a single handler.
- `oauth_server/api/metadata.clj` — the `/v2` RFC 9728 document removed. It
  advertised `:resource` as the `/v2` URL, telling clients to send an indicator
  nothing would recognise.
- tests — repointed at the canonical path, as the migration comments said the
  switchover would ("The final switchover PR repoints this at metabase-mcp").
  `oauth_server/core_test` needed real edits rather than a find-and-replace:
  `/v2` was one of three paths in the union tests, so swapping the string would
  have left them comparing a path against itself and quietly asserting nothing.

mcp.v2.api-test 134 assertions, transport-test 218, oauth core+api 338 — all 0
failures / 0 errors. :module mcp 3485 assertions, one pre-existing error in
query-test. Full-tree kondo 0/0. No reference to the retired path survives
outside one historical note in the paths docstring.

* Apply cljfmt to mcp.v2.tools.search

Whitespace only; the Cljfmt CI job was red on this file.

* Fix MCP Apps dropping bound template-tag values when rendering a handle

`execute_sql` mints a query_handle whose payload is deliberately wider than a dataset
query: the values bound to {{tag}} placeholders ride alongside it under `parameters`,
because `prepare-for-serialization` strips `:parameters` from the query as runtime-only.

`getMcpDeserializedCard` parsed that whole payload into `dataset_query`, so the bound
values ended up nested where nothing reads them, and the run assembled its parameters
from the card instead. Visualizing a handle re-ran the SQL with every {{tag}} unbound.

Split the payload instead: the dataset query becomes the card, and the bound values
become `initialSqlParameters`, keyed by template-tag name. That is the form the SDK
matches against the question's tag-derived parameters (by slug). Putting them on the
card would not work — they carry no `id`, and a card-level `parameters` array
short-circuits the very tag-derived list they need to be matched against.

* Restore the response schema on GET /collection/root/items

The merge from master kept the branch's endpoint body, which predates master adding
`:- ::ItemsResponse`, along with two suppressions that the branch no longer needs: the
kebab-case one went stale when the query params were renamed, and the response-schema
one put the tree one over budget on two ratchets.

* Name the required scope in the scope-denial error

The refusal read "Insufficient scope to call tool: bookmark_content" — the tool
the caller already named, and nothing it could act on. A fleet worker holding a
read-only token recorded that this contradicts the server's own `initialize`
instructions, which promise "a failed call always names its fix", and that it
had no way to learn which scope to request.

Both values are in hand at the branch: it fires on
`(not (mcp.scope/matches? token-scopes (:scope tool)))`, so the required scope
and the held set are bound right there. The message now carries them:

    Insufficient scope to call tool: bookmark_content.
    Requires agent:content:write; your token holds agent:content:read.

`:scope` may be a set of alternatives ([[mcp.scope/matches?]] accepts either),
which reads as "Requires one of a, b"; `token-scopes` may carry the
`::api.scope/unrestricted` keyword, which is not a scope a caller can request,
so only strings are listed back.

The tests changed because twelve of them pinned the old string exactly. They
now match an anchored prefix, so the tool name each one cares about is still
asserted while the scope detail is free to change; `registry-test` asserts the
full new shape, including the held-scope clause.

Verified: `:module mcp`, 3525 assertions, 0 failures. The one remaining error
(`query-test/unknown-caller-key-never-reaches-the-qp-test`) reproduces
identically on the unpatched tree — it is the closed-options-schema breakage
from the master merge, unrelated to this change.

* Return collection_path from get_content, as search already does

`get_content` returned a bare `collection_id`, so an agent asked where an item
lives had to spend a second call resolving the collection. Two fleet workers hit
this on the same task; one made the extra call and then noted it was redundant,
because `search`'s own rows had carried `collection_path` all along.

The path walk moves to `v2.resolve` so both tools share one implementation
rather than forking the permission handling: ancestors the caller cannot read
are omitted rather than failing the path, matching
[[collection/effective-ancestors]]'s breadcrumb semantics (A > B > C with B
unreadable reads "A/C"). `search` keeps its own batched version — it walks whole
result pages and its two lookups are per page, not per row.

`collection_path` joins `question-enrichment-keys`, the set of projection keys
computed at read time rather than selected as Card columns, so the column-select
paths (`list_models`, browse rows) drop it as they already do for
`query_summary` and `template_tags`.

Verified against a live instance: card 20 returns
`"collection_path":"Examples"`, matching what `search` reports for the same
card, and a root item returns nil rather than an empty string.

* Move the GHY-4313 unknown-key test onto the path the whitelist protects

Master closed `:metabase.lib.schema/query` in #82196, so a fresh `query` carrying an
unknown top-level key is now rejected upstream rather than reaching the QP input
whitelist. The test asserting the key is dropped ran on that path, so it errored on the
rejection before exercising the guard it exists to prove.

The guard is `select-keys` against `query-passthrough-keys`, not the schema, and the
distinction matters: the closed schema covers only the fresh path. A handle's stored
query is checked shallowly (non-empty stages, positive last-stage limit) and the
`/drills` callback stores a caller-supplied query verbatim, so an unknown key really
does reach the execution boundary there. Move the test to the handle path, where the
whitelist is load-bearing, and add a second test recording the fresh path's rejection.

Verified by mutation: replacing the `select-keys` with the raw query turns the handle
test red on `(not (contains? captured :evil))`.

* Test the MCP App parameter fix against the payload execute_sql actually mints

The spec asserted against a legacy `dataset_query` shape that was invented, not observed.
The real serialized query is MBQL 5 — `stages` rather than `dataset_query`, and
`template-tags` as an array rather than a map — captured from `prepare-for-serialization`
in the REPL. The extraction happens to be shape-agnostic, since it reads only
`parameters[].target`, so the tests passed against a fiction and proved nothing about
what the embed receives.

Rebuild them on the real payload and add the case that decides it: drive
`getCardUiParameters` and `getParameterValuesForQuestion` — the helpers
`loadQuestionSdk` uses — and assert the tag-derived parameter resolves to slug `cat` and
takes the bound value. Splitting the payload cleanly is worthless if the halves don't
rejoin there.

The value arrives array-wrapped because `normalizeParameterValue` wraps every string- and
number-typed parameter on the way to the request; that is the shape a filter widget
produces too.

Verified by mutation: making the parameter extraction return {} turns both value-carrying
tests red.

* Cover the MCP App parameter chain through to the request payload

Extends the binding test to the full path `loadQuestionSdk` walks: build the Question
from the card, apply template-tag parameters, derive values from initialSqlParameters,
set them, then assemble request parameters as `runQuestionQuery` does. Asserts the QP
receives the bound value against the target the tag declares.

This is the level the bug lived at, so it is the level worth guarding: the earlier tests
only prove the payload splits, and a clean split is worthless if the halves do not
rejoin.

* Correct the keyset-marker stripping claim in resolve-input

The comment said caller-supplied queries are stripped in "the `:query` branch above and
the `/drills` callback". `strip-caller-keyset-markers` has exactly one call site, the
`:query` branch — `POST /api/embed-mcp/drills` stores its caller-supplied query verbatim.

Documenting a guard that isn't there is worse than documenting the gap, so say what the
code does and why the gap is tolerated: reaching it needs the scoped UI credential, and
the effect is confined to the caller's own narrowed-question reporting.

* Fix Duplicate key crash in document collection-items tests

`#{normal-card-id dash-id in-document-card-id}` is a runtime set, and
`PersistentHashSet/createWithCheck` throws on duplicates rather than deduping. Card and
Dashboard ids come from separate sequences, so in a fresh app DB where this namespace is
the first to create both, each gets id 1 and the literal blows up before any assertion
runs. It survived full-suite runs, where thousands of preceding rows keep the sequences
apart, and failed only under `:only`.

Match on (id, model) pairs instead. The vectors differ by model string, so no duplicate is
possible, and it also closes the correctness hole the bare id set had — an unrelated
Dashboard sharing a Card's id would have been pulled into the filtered set.

Applied to the latent third instance in the trash-items test too, which spans Document,
Card, and Dashboard sequences and was one id alignment from the same crash.

metabase.documents.api.collection-test: 11 tests, 66 assertions, 0 failures (was 64 pass,
2 errors).

* GHY-4510 Withhold query_summary when a source card is unreadable

get_content on a card the caller can read rendered `query_summary` with
`lib/describe-query` over `application-database-metadata-provider`, which applies no
per-user read check. For a wrapper whose query sources a restricted card, that resolved
and printed the restricted card's display name — the one read surface that leaked it,
while the direct read 403s, the run path refuses via `check-card-read-perms`, and search
filters it out.

Gate the summary on the caller being able to read every card the query reads, at any
depth (`all-source-card-ids-recursive`, so a transitively nested source counts too).
Fails closed: a missing source card, or a query that can't be walked, withholds the
summary rather than rendering from metadata we cannot account for.

question, model, and metric all resolve through `fetch-card`, so one gate covers the
three types the report named.

The test drives the report's attack — restricted card in a revoked collection, readable
wrapper sourcing it — and asserts the name appears nowhere in the response, not merely
that `query_summary` is nil. Verified failing first: `query_summary` came back as the
restricted card's name verbatim.

metabase.mcp.v2.tools.content-test: 41 tests, 224 assertions, 0 failures.

* Placeholder the table and field ids in execute_query's examples

The description stated `ORDERS = table 5` and `CREATED_AT = field 42` as fact.
On a stock Sample Database ORDERS is table 2, and table 5 is FEEDBACK — a real
table — so an agent copying the example queries the wrong table and gets a
plausible answer with no error anywhere in the trace.

A fleet worker did exactly that: `execute_query {stages:[{source-table: 5,
limit: 5}]}` returned FEEDBACK's columns (ID, ACCOUNT_ID, EMAIL,
DATE_RECEIVED, RATING, ...). Nineteen of twenty-five workers on one case
flagged the discrepancy and looked the id up instead; the ones who did not
would have had no signal.

Correcting the numbers would not fix it. The ids are per-instance, and the
fleet saw 5 resolve to FEEDBACK on most instances and REVIEWS on another from
the same seed data — so any literal id in this docstring is wrong somewhere.
`<TABLE_ID>` and `<FIELD_ID>` cannot be pasted.

The same description already said "never invent or guess ids — discover ids
first", which its own examples contradicted.

* Decouple the MCP App parameter test from getParameterValuesForQuestion

CI type-checks the PR merged with master, and master changed that helper's signature from
{card, metadata, queryParams} to {card, queryParams, buildQuestion}. The spec compiled on
the branch and failed on the merge — a semantic conflict, clean to merge and broken to
type-check.

Call `getParameterValuesByIdFromQueryParams` against `question.parameters()` instead. That
is precisely what the helper delegates to on both sides — the old form passed
`getCardUiParameters(card, metadata)`, the new one passes `buildQuestion(card).parameters()`,
and `Question.parameters()` is `getCardUiParameters` over the same card — so the test
asserts the same semantics while no longer depending on a signature in flux. Both
primitives it now uses are byte-identical on master.

8 tests green; type-check, eslint, and oxfmt clean.

* GHY-4511 Make a question's visualization_settings readable (#82355)

* GHY-4511 Make a question's visualization_settings readable

A chart's settings could not be read back: not in concise, not in detailed,
not through `fields`, and `question_write` did not echo them either. An agent
could write a goal line or a series binding and had no way to check it.

- `get_content` gains a `settings` include for question/model, returning the
  stored `visualization_settings` verbatim ({} when nothing is stored).
- `visualization_settings` joins the question detailed projection, which also
  makes it a valid `fields` path. It stays one whole-blob path: its own keys
  contain literal dots, so nothing deeper could ever be addressed.
- `question_write` echoes `visualization_settings` in its response.
- A `fields` path with no stored value now answers null instead of dropping
  out, so an unset `cache_ttl` no longer looks like an unreadable field. A
  path through a missing parent still drops — a null-valued shell would claim
  a parent that isn't there.
- The visualization-settings skill's "To confirm" section now describes what
  ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUQL4KAPYRe7DJbwRF4dTy

* Name the include after the property it returns

Review: `include: ["settings"]` returned `visualization_settings`, so the
section name and the property name had to be translated by anyone reading
either. `visualization_settings` is also the name every other surface uses —
the REST API, question_write's args, the card model, the skill — so it is the
one a model reaches for unprompted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUQL4KAPYRe7DJbwRF4dTy

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* GHY-4512 Read a link card's entity back behind a read check (#82357)

`add_link` with `entity` saved correctly all along. `get_content`'s `layout` include stripped `:entity` from every link card on the way out, so an entity link read back as `link: {}` — indistinguishable from an empty card, and impossible to round-trip through `patch_dashcard`. The strip was there to stop the `name`/`description` cached beside the reference at write time from reaching a caller no read check stands behind.

`dashboard-layout` now hydrates `:dashcard/linkcard-info`, the same batched hydration the REST dashboard endpoint runs, and returns the bare `{model, id}` reference — the shape `patch_dashcard` takes back verbatim — when the caller can read the target, and `{restricted: true}` when they cannot. The cached name and description still never leave the server.


Claude-Session: https://claude.ai/code/session_019aHyUCgLuS6QzkcRcZDcdd

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* GHY-4516 Let collection_write create transform folders (#82356)

browse_collection browses the transforms namespace and transform_write
files a transform into a collection_id, but collection_write's namespace
enum was "snippets" only — so the folders those two tools talk about
could not be made through MCP at all.

Update needed no change: namespace is already create-only, a move stays
inside the row's own namespace, and parent_id "root" lands at the root of
the transforms tree rather than the content root. Tests pin that.


Claude-Session: https://claude.ai/code/session_015dqtq17qCpVgMeRvu9uPWt

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* GHY-4517 Honor `name` on measure clauses (#82358)

The measures middleware substitutes a measure's underlying aggregation for
the `:measure` clause, carrying `:lib/uuid` and `:display-name` across but
dropping `:name`. A caller-supplied `{"name": "revenue"}` passed validation
and was then silently discarded, so the output column came back as the
underlying aggregation's name (`sum`, `count`) and a next-stage
`["field", {}, "revenue"]` failed with a raw database error.

The metrics middleware already preserves `:name`; a regression test now
covers that too. Also documents in the query-dialect skill what a metric's
or measure's output column is called.


Claude-Session: https://claude.ai/code/session_01Egi1Dqi8XGihQzQ5BmQYat

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the MCP Apps iframe working when an endpoint declares a scope (#82361)

* Let enforce-scope defer to the MCP Apps UI route gate

An MCP Apps UI credential is not an OAuth token, and an endpoint's declared `:scope` is not
the vocabulary that confines it. `metabase.mcp.ui-surface/request-surface` is — a handful of
routes, each priced in the MCP scope the minting session had to hold — and it is strictly
narrower than any endpoint scope could be.

Those two mechanisms currently refuse each other. The credential carries `::mcp-ui`, which
satisfies no declared `:scope` by design, so declaring one on any route of that surface 403s
the iframe while every other caller is unaffected: an ordinary session carries
`::unrestricted` and passes. It merges clean and fails silently — the iframe stops rendering.

`ensure-scopes-checked` already honours `:token-scopes-checked` as "an authorization decision
already happened upstream". `enforce-scope` did not, and that asymmetry is the bug. The MCP UI
stamp is set only when the surface's own scope gate passes, so honouring it defers to a
route-specific decision rather than waiving one.

Conditioned on `::mcp-ui`, not on the stamp alone: `enforce-scope` sets the stamp itself on
success, so trusting it unconditionally would make every per-endpoint `:scope` a no-op
underneath a namespace-level `enforce-scope`. The test asserts that non-leak directly.

* Guard every MCP UI surface route against the endpoint scope layer

Drives each route on `request-surface` with a real UI credential through the real stack and
asserts the response is not a scope rejection — neither `unsupported_scope` from
`enforce-scope` nor `scope_not_permitted` from `ensure-scopes-checked`.

Asserting on the error codes rather than on a status or a scope name is what makes this
independent of whichever scope a future branch declares, and of what the handler then does
with a deliberately thin probe body: a 400 is a pass, because the request reached the handler.

A coverage assertion pins the probe set to the surface, so adding a route without a probe
fails rather than silently skipping the check.

Verified by mutation, both directions: declaring a `:scope` on POST /api/dataset turns this
red on exactly that route with the carve-out removed, and green with it in place.

* drop v2 ping tool and update llm-install (#82362)

* update llm-install

* drop v2 ping

* Drop the require comments and redundant keep-me around test_echo

* GHY-4518 Summarize a native question by its SQL, and name its database (#82363)

`query_summary` delegates to `lib/describe-query`, whose native-stage display
name is the placeholder "Native query" — enough in the UI, where the SQL renders
beside it, and nothing at all to a caller holding only the row. An audit of an
all-native dashboard had to guess each card's purpose from its description.

Native cards now summarize as the head of their own query text: whitespace
collapsed to one line, capped at 300 characters. The full query stays behind
`include: ["definition"]`, so the summary can stay a summary. A non-string
native query — Mongo stores a map — keeps the generic Lib description rather
than rendering a map.

Both branches stay behind the `source-cards-readable?` gate from GHY-4510: a
native query names a Card in a `{{#123}}` template tag as readily as an MBQL
stage names one in `:source-card`, and `all-source-card-ids` covers both.

The row also carries `database_name` now, read off the metadata provider it
already builds. Resolving `database_id` to a name previously cost a second
`search` call. It is flat rather than nested to match the vocabulary `search`
rows already use, and it joins `question-enrichment-keys` so the column-select
paths drop it instead of selecting a column that does not exist.


Claude-Session: https://claude.ai/code/session_01GXJcdTASE4jxhmE4fzRNkw

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* GHY-4519 Answer a date parameter with its range, not hundreds of dates (#82366)

`get_parameter_values` returned every distinct date in the column for a
`date/*` parameter — 721 on one dashboard, 100 per page — when what a caller
needs is the span and the grammar it can filter with.

A column-backed date parameter now answers:

    {"kind": "date", "min": "2016-04-30", "max": "2020-04-19",
     "distinct_dates": 18760,
     "accepts": ["YYYY-MM-DD", "YYYY-MM-DD~YYYY-MM-DD", "past30days", "thisyear"]}

The range is aggregated rather than folded out of the fetched values, because
listing stops at `result-limit` and values come back ascending: the list's last
value is the 1000th-earliest, not the column's max. On orders.created_at that
gap is fourteen months — the list ends at 2019-02-22 against a real max of
2020-04-19. `chain-filter-range` builds the same source table, joins and
constraint filters the values query builds, aggregated to one row, so
constraints narrow the range exactly as they narrow a list.

Excluded: a date parameter with a static-list or card source, whose dates are a
curated set to pick from rather than a span to write a value inside.

`query` on a date parameter is now a teaching error. A range is two dates and a
grammar, so there is no list to search, and applying it silently would hand back
the whole span as though it had been narrowed.


Claude-Session: https://claude.ai/code/session_01GR7MMvsg2kH4dE24YyX1kS

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review nits in useMcpApp

- Drop the unjustified cast and FIXME on window.metabaseConfig; the global
  Window.metabaseConfig declaration already types instanceUrl.
- Remove the redundant void operator on the async IIFE.
- Remove the redundant JSDoc on McpAppState.queryError.

* Address review: drop the unreachable v1 payload path, trim comments

Review feedback from sanex3339 on #81245.

Two of the comments were questions — whether the iframe can still receive a v1 response, and
whether the v1 field needs describing, given FE and BE ship from one instance. The answer is
that it cannot, and more strongly than the questions assume: v1 was deleted from this branch
in 84c334c6d2, and both v2 UI tools put `{query_handle}` in `structuredContent` and nothing
else (their output schemas declare only that key). So the inline `query` field, the branch
that read it, and the comments explaining both are dead. Removed.

That removal surfaced why the dead path survived: 8 tests drove the v1 inline shape, so the
suite exercised a payload the backend cannot produce. The fixtures now carry handles — the
real shape — and resolve through the mocked callback API. One test asserted the dead path
outright ("still renders v1's inline query") and is gone with it.

Also replaced the `// Unjustified type cast. FIXME` on `structuredContent` with a runtime
narrowing that checks each field instead of asserting the whole shape, and shortened the
comments flagged across the four other files. Kept the two that carry a hazard rather than a
restatement: the RTK subscription warning in `useMcpUserAndSettingsFetch`, and why
`McpQuestionView`'s spec must not use `mockSettings`.

20 suites, 68 tests green; type-check, eslint, oxfmt clean.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aleksandr Lesnenko <alxnddr@gmail.com>
Co-authored-by: Aleksandr Lesnenko <alxnddr@users.noreply.github.com>
2026-09-11 16:30:12 -05:00
github-automation-metabase
6931416988 Auto-update documentation for master (#82382)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 21:18:17 +00:00
github-automation-metabase
90e121c475 Tighten ratchets (#82377)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 20:40:40 +00:00
Oleksandr Yakushev
7c6dad4ec9 [perf] Implement dropv and use it in metabase.util.match (#82010) 2026-09-11 23:34:54 +03:00
github-automation-metabase
7078a59606 Tighten ratchets (#82374)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 20:18:24 +00:00
Chris Truter
4f02379e40 Organize backend modules into domain subtrees (#82367)
Group things in a way that seems natural
2026-09-11 20:12:43 +00:00
Andrei Simionescu
062d281192 BOT-1956: Fix permission-check gaps in transform/query export (#79877)
Audit a permission refusal only when someone reaches for something themselves,
the model naming an ID or an ID inside a query the client sent us. Refusals hit
while rendering a query one of our own tools wrote stay quiet, so the log keeps
only the ones that are a real access attempt.

Show a query only if you could run it, using the query processor's own check
rather than read access to the database, and check every table and column the
rendering would name so column sandboxes are caught too.

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-09-11 21:07:30 +01:00
Rodrigo López Dato
9d022339f5 Make mapQuestionClickAction docs more concise (#81851)
* Make mapQuestionClickAction docs more concise

* Separate links
2026-09-11 16:58:37 -03:00
Chris Vandoodewaard
14fe567687 only auto-patch current version (#82368)
only auto-patch latest
2026-09-11 14:56:29 -04:00
technomancy
daceb36e65 Snowflake schema specified in :additional-options should take precedence (#82364)
Fixes https://github.com/metabase/metabase/issues/65493

If you have a `details.schema` property set in your connection (this was
removed in v42 in favor of schema filters), you can't change it in the
UI in the current Metabase version.  If you wanted to specify a schema
through the "Additional JDBC connection string options", the old
buried schema property would take precedence anyway.

This makes it so the deprecated field is removed when the schema is
set in additional-options.

I've verified this using a real connection and querying `SELECT current_schema()`
as suggested in the bug report.
2026-09-11 11:20:31 -07:00
github-automation-metabase
1c561d056c Tighten ratchets (#82365)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 17:42:07 +00:00
Chris Truter
dd9d8d0579 Ratchet module boundary escape hatches (#82300)
Protect against bad things happening because agents and reviewers aren't aware that they're bad.
2026-09-11 17:36:42 +00:00
metamben
d0cbe49c68 Prime app-db connection-pool before use (#81601)
* Prime app-db connection-pool before use

Closes QUE2-833
Closes #81440

* Fix linter warnings
2026-09-11 17:08:38 +00:00
Nick Fitzpatrick
3e83533d06 BE PDFs - target 300dpi for rendered visualizations (#81078) 2026-09-11 13:55:16 -03:00
Chris Truter
eff3209a34 Add hierarchy to the backend module system (#82301)
This will allow us to resolve a lot a cycles in the module graph, and increase encapsulation considerably.
2026-09-11 18:48:15 +02:00
Alex Yarosh
93aabef5d3 docs: expand data modeling overview (#82309)
* data modeling overview

* copy paste strikes again

* Apply suggestions from code review

Co-authored-by: Michael Eby <38187276+michaeleby1@users.noreply.github.com>

---------

Co-authored-by: Michael Eby <38187276+michaeleby1@users.noreply.github.com>
2026-09-11 16:37:49 +00:00
Anderson
1a8883d604 Revert "Apply responsive spacing and typography to dashboard chart cards" (#82346)
* Revert "Apply responsive spacing and typography to dashboard chart cards (#81…"

This reverts commit 13e00d55b9.

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 13:10:36 -03:00
Timofey Kachalov
0cf4be2fd2 Fixed MCP search response schema validation (#82205) 2026-09-11 20:02:54 +04:00
Eric Normand
615cfdb48b Fix race in detached-fetch!-sweeps-stalled-fetches-test (#82347)
The test read `:future-ref` straight off the registry entry as soon as the
fetch signalled it had started. But `detached-fetch!` registers the entry
before the fetch begins and only stores the future just after submitting it,
so the thunk can deliver `started` before the `reset!` lands. When it does,
`stalled-future` is nil and `future-cancelled?` throws an NPE.

Under 300 spinning threads a probe caught the window 1-4 times per 20000
fetches; inserting a 5ms sleep between the `future` and the `reset!` makes
the old test fail every run.

Poll for the future instead. Only the test is affected: the sweep acts on
entries older than *fetch-max-age-ms* (an hour), so it can never observe one
whose `:future-ref` is still unset.


Claude-Session: https://claude.ai/code/session_01J1yFPpYiWH2ik8LB9rs7BT

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:56:49 -05:00
Alex Yarosh
19989ab207 docs: cloud migration (#82305)
* Revise migration steps 

Updated migration guide to clarify steps and include images.

* Delete docs/cloud/images/migrate-button.png
2026-09-11 15:36:37 +00:00
Alex Yarosh
efb95e2e18 docs: add glossary article (#82308)
* add glossary article

* Optimized 2 images, saved 219.4 KB (42.5% smaller)

* link

* Update docs/data-modeling/semantic-layer/glossary.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-09-11 15:29:28 +00:00
Riley Thompson
3ad97e619d Use multiple jvms per runner for bigquery tests (#81204)
* multiple jvms per runner for bigquery

* trigger CI

* add failing test

* lower timeout

* remove debug stuff

* clean up comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-11 11:27:34 -04:00
Anna Tomka
5b7c89d8ee GDGT-2478 Translate PasswordInput toggle label at call time (#82338)
* GDGT-2478 Translate PasswordInput toggle label at call time

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 15:26:10 +00:00
Riley Thompson
05d2f186a0 Use calendar days for bigquery datetimeDiff (#82194)
* BigQuery: use calendar days for datetimeDiff day unit

Fixes #82193

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Avoid :raw for date part in BigQuery day diff

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* clean up

* add timezone to test

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 10:53:37 -04:00
Phoomparin Mano
c8f77a0925 Data Apps Permissions (#79822)
* add saved question extraction prototype

* fix additional failure cases

* refactor: reconciler

* revert me: manual skill file edit for source swapping

* set data permissions to view for databases referenced in the query

* rename data app collection and group

* prevent agents from removing savedQuestionSourceId

* docs: clarify data app query location

* chore: cleanup query-sync canonical module

* refactor: split query tests into different files

* Swap query source for data app query hooks and apply dynamic filters on top of it (#79584)

* code improvements and bug-fixes for data apps permissions

* Add data apps model imports

* address reviews on data apps permissions

* run sync queries before vite build

* Add data app resource link (#79878)

add button to view data app resources

* Show permission error when users cannot access a data app (#79874)

show permission error page when user do not have permission to view data apps

* Add data app group link in overflow menu (#79873)

Add data app group link

* Store and reconcile data app entity ids in data_app.yaml (#79833)

* Store data app resource entity IDs

* make entity ids in data_app.yaml authoritative

* add top-level value after path key

* simplify data_app.yaml manifest parser

* only allow changing data app collection if empty

* consolidate backend test helpers and remove redundant tests

* chore: restore README changes

* ci: fix failing tests

* Add support of actions to Data App reconciler (#79842)

Add support of actions to Data App reconciler

* Fix bugs/inconsistency and add more e2e for data apps reconciler/syncing (#80002)

* Update semantic schema skill about preventing segments and measures in dynamic parts of a query in Data Apps (#80288)

* Grant view data access on a table-by-table basis in data app groups (#80189)

* grant view data on table instead of database level

* refactor table scan in reconciler

* update e2e tests for permission grants

* Grant data app view permissions per table

* Skip unchanged data app permissions

* add e2e data app test for sandboxing

* reset host app sources after runs

* Preserve sandboxing for data app users (#80406)

* add e2e data app test for sandboxing

* improve e2e tests for data apps

* Remove sandbox E2E from table permissions change

* preserve existing sandboxing from sandboxed groups in data apps

* assert that we grant non-sandboxed tables

* Explain data app sandbox exclusion

* Add data app sandbox E2E coverage

* Exercise sandbox preservation in data app E2E

* Disable saved question sources in data apps (#80139)

* Disable saved question sources in data apps

* Preserve question-only schema scope

* Remove the saved question schema parameter

* Copy table-sourced metrics into data app collection and do not codegen card-sourced metrics (#80086)

* copy table-sourced metrics into the data app collections

* copy table-sourced metrics into the data app collections

* Address metric sync review feedback

* Accept keyword metric display in query response

* Fix data app metric reconciliation ordering

* Remove old lockfile format

* exclude card-sourced metrics from schema generator

* do not use card terms in metrics to avoid ambiguity with card-sourced metrics

* fix model imports

* Fix data app metric reconciliation

* refactor: cleanup reconciler unit tests

* Fix duplicated metric client helper

* add safe metrics path

* Handle unsupported metric dependencies

* Fix data app CI test regressions

* Fix "Re-adding removed query fails to adopt deleted card" (#80341)

* Clean-up data apps permissions endpoints (#80825)

* Remove entity ids from data_app.yaml (#81317)

Remove data app entity ids from data_app.yaml

* Adjust data apps permissions code (#81093)

* Remove the data app table permission grants (#81584)

* Add permissions_group.is_data_app_group and hide data app permission groups from UI (#81705)

* Add data app user management and access warnings (#81839)

* Address data app user access review feedback

* additional e2e test cases for bugs in manage users page

* fix failing frontend type check on unit test

* fix migration id collision for data apps

* combine migration changes into one file

* fix data app metric sync after trashing copied metrics

* fix cli runtime dependency packaging for embedding sdk

* avoid loading data app bundle during resource setup

* prevent redundant data app collection permission updates

* allow admins to remove data app members after token expires

* update docs and add e2e test for missing resource collection case

* isolate data app sync using transactions

* do not leave user on an empty page when removing the last member on the page

* reserve sandbox-host slug in data app config

* ignore generated data app action fixtures

* move queries into db.clj

* preserve legacy permissions when adding data app members

* regenerate the kondo modules config

The data-apps module reaches for :model/Permissions and :model/DataPermissions,
neither of which was recorded in :model-imports, so metabase.core.modules-test
fails on the current tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* reject data app queries whose metrics read another card

Sync copies a referenced metric but rewrites nothing inside the copy, and
copies nothing that copy in turn reads, so those references still point at
the originals. The app publishes successfully and then fails for every
viewer without access to the originals' collections.

Checking while resolving the query, rather than at codegen, also catches a
metric edited into that shape after its schema was generated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* exclude metrics that read a saved card anywhere from generated schemas

source-card-id only reads stage 0, so a table-sourced metric that joins a
saved question passed the codegen filter. The CLI checks the whole query and
rejects such a metric, aborting sync-resources for any app that uses it.

Checking the whole query here keeps codegen consistent with what sync
supports, and removes the database round trip that asked which of the
referenced cards were metrics -- the step that let a saved-question join
through in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* resolve copied resources' table dependencies through the server

Numeric source-table entries miss implicit joins: a copied model querying
Orders and reading Products.Category through a foreign key names Products
nowhere, so it was left out of the app's permission warnings.

Run the same metadata-based lookup the query endpoint uses, through a new
endpoint the CLI calls for copied models, actions and metrics, and drop the
JSON walk it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* cover the create-queries grant the database defaults leave on app groups

An app group that predates the first non-audit database is given
create-queries :query-builder-and-native by set-new-database-permissions!,
alongside the :blocked view-data it special-cases for app groups. A user with
data access through another group would gain native query authoring just by
joining the app.

The reconciler already resets it, but nothing failed when :perms/create-queries
was dropped from its list. The pairing cannot be produced through
set-database-permission!, which cascades view-data to :unrestricted for any
create-queries value other than :no, so the test writes the row the way the
defaults insert it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* prevent models based on saved questions from being copied

* return to a valid member page after overlapping removals

* do not resolve query objects when either enabled flag is false

* prevent shorthand properties and spreads from overriding generated ids

* prevent adding duplicate members and retry only members that are failed to add

* define schema for data app query endpoint

* reconcile only the current data app group during resource setup

* expand the schema fingerprint

* add data app group field in permission group schemas

* prevent new databases from granting query creation to data app groups

* throw validation errors when invalid query spec fields are passed

* do not fail validation on columns metadata

* add enabled metadata to query definition schema

* accept more data app specific query metadata and binning options

---------

Co-authored-by: Timofey Kachalov <timofei@metabase.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:41:01 +01:00
Timofey Kachalov
1626b31be7 Fix flaky child-frame realm gate in the data-app sandbox (#82324) 2026-09-11 18:07:04 +04:00
metamben
2659b87941 Throw on uncoerceable user attributes (#82118)
Closes QUE2-842
Closes #81821
2026-09-11 14:05:29 +00:00
metamben
b2c923e496 Remove explicit :fields on the last stage of pivot queries (#82111)
Closes QUE2-825
Closes #81203
2026-09-11 17:03:42 +03:00
Chris Vandoodewaard
cc1fa36312 add workflow combining quick and simple PR checks (#82126)
* combine workflows into single pr.checks.yml

* fixes and better checkout

* ignore parallel lint failure

* add filter to checkout to avoid slow checkouts

* remove check
2026-09-11 13:59:23 +00:00
github-automation-metabase
498dfc7435 Tighten ratchets (#82331)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 13:16:25 +00:00
Ben Grabow
6780dbbf52 Allow multiple intermediate ns segments (#82125)
* Allow multiple intermediate ns segments

* Detangle llm settings

# Conflicts:
#	src/metabase/llm/provider.clj

* Move settings.providers to providers.settings

* Move providers.settings to provider.settings

* Loosen filter to include all `metabase.llm.*` settings

* Merge from master

* Delete obsolete linter ignore

* Clarify linter message
2026-09-11 08:11:09 -05:00
Jeff Bruemmer
86f64c3d9f docs - update docs job (#82327)
update docs job
2026-09-11 08:57:59 -04:00
Anna Tomka
ca17b89463 GDGT-2478 PasswordInput (#82117)
* test(ui): add PasswordInput Storybook story

* test(ui): add PasswordInput to Loki as baseline for restyle

* feat(ui): restyle PasswordInput from Figma and add input-background token

* test: cover PasswordInput visibility toggle in unit and e2e tests

* fix(PasswordInput): use highlight border color in focused error mode by overriding Input styles

* Update Loki Snapshots

* test(ui): add Loki reference for PasswordInput Overview story

* fix(PasswordInput): address review feedback

- keep border width constant and use an inset ring for focus/error to avoid layout shift
- tie right padding to the toggle button size so text clears it in lg
- restrict size prop to sm | md | lg
- order color keys alphabetically
- explicit visibility assertion in the e2e spec

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 14:18:41 +02:00
Anna Tomka
38b7b8c0b7 GDGT-3212 add missing inner border in dark theme (#82203)
* fix(Pill): add missing inner border in dark theme

* fix(Pill): drop explanatory comment per review
2026-09-11 14:18:11 +02:00
Kevin Kelleher
94e58da90d Fix auto-wiring dashboard filters to dashcards with multiple series (#81913)
* handle auto-wiring dashboard filters to dashcards with multiple series

* review feedback
2026-09-11 07:24:06 -04:00
Oleh K
6a035e234c Use JetBrains Mono as the code font (#82278)
* Use JetBrains Mono as the code font

Ship JetBrains Mono (regular + bold) and point the monospace font family
at it, replacing the hardcoded Monaco/Menlo stacks scattered across the
codebase.

- Add the woff2 files, @font-face declarations, and the type, fallback
  map, docs and backend test entries that keep the admin font picker in
  sync with the fonts directory.
- Route every monospace call site through
  --mb-default-monospace-font-family, and define that variable in the
  embedding SDK's variable set, where it was previously missing. With
  the variable always defined, the per-call-site `, monospace` fallback
  is redundant and is dropped.
- Inline the variable at the CodeMirror theme, which was the only
  consumer of monospaceFontFamily, and delete typography.ts.
- Set the Code component to 13px/22px per the design spec.

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 12:12:15 +02:00
Andrei Simionescu
b75caae5f3 AI settings docs: a provider connection takes credentials, not just an API key (#82026)
Bedrock takes an AWS access key pair and Google Gemini Enterprise a service
account key file, so the AI settings page can't keep calling every provider
connection an API key.
2026-09-11 09:18:39 +01:00
Fraser
a5af036433 Rehome the analytics export machinery to EE audit_app (#79808) 2026-09-11 19:04:08 +12:00
Romeo Van Snick
1c5ba041d6 Stop the metabot viewing-context tests from depending on absent entity ids (#82248)
The tests pass ids like 789 for entities they do not create. format-entity looks the
id up, and the test asserts on the fallback rendering that a 404 produces. When a
parallel test happens to hold that id, read-check gives a 403 instead, which renders
nothing, and the test fails.

Use Integer/MAX_VALUE, which no fixture allocates.


Claude-Session: https://claude.ai/code/session_01GCoSa7L7KrXh42rDRYR3Nk

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 15:11:30 +10:00
Romeo Van Snick
86c23eaf5e Read a card's UI parameters off its question (#82238)
* Read a card's UI parameters off its question

* Drop a tombstone comment

* Pin the agreement between getCardUiParameters and Question.parameters

* Type the unsaved card in the agreement test

* Format the agreement test
2026-09-11 15:11:21 +10:00
Fraser
1447d9cf97 Move the application permissions selector slot into current-user (#81846)
* Move the application permissions selector slot into current-user

PLUGIN_APPLICATION_PERMISSIONS.selectors was only read by current-user, so the slot now lives there as PLUGIN_APPLICATION_PERMISSIONS_SELECTORS with its OSS defaults, and the EE application permissions plugin registers into it from metabase/current-user. getRoutes and tabs stay in metabase/plugins for the admin permissions editor.
2026-09-11 16:42:11 +12:00
Romeo Van Snick
90688e49a7 Translate at call time, not at import time (#82147)
* Translate viz nouns and formatting operators on demand

`noun` was the one field on a visualization definition still translated at
import. `get noun()` matches what ObjectDetail and TreemapChart already do,
and the registry stores a definition by reference, so the getter survives.

The table-formatting operator names were four module-level objects. They are
functions now. `ALL_OPERATOR_NAMES` becomes `getAllOperatorNames()`, which
its three readers call.

25 of the 124 sites that translate before a catalogue can be applied.

* Translate settings titles on demand

`title` in a settings definition follows the same rule as the rest of the
object. 152 settings already use `get title()`; these 23 were the laggards.

The group-managers confirmation held two frozen strings, so it becomes a
function rather than an object with getters.

49 of 124.

* Lift translated containers into functions

Where a whole array or object is translations, the function goes on the
container so consumers keep a plain array or object: PieChart's ring titles,
the whitelabel illustration options and descriptions, PivotTable's partitions
(which held JSX built at import), the model-caching options, the cloud
migration messages, and the geo filter placeholders.

Where only a field or two of a mixed config is translated, and call sites read
it by property, the getter goes on the field instead. That covers the
collection configs, the permission option records and the loading messages,
none of which are spread anywhere.

Four files carried a whole-file disable rather than one per site, hiding 20
sites behind 4 directives. The real total is 140, not 124.

118 of 140.

* Clear the last module-scope translations

Validation schemas become functions, matching getOidcFormSchema and the other
lazy schemas already here. Module-local error strings become functions called
at their use sites. Three exported constants follow: NULL_DISPLAY_VALUE,
BEFORE_UNLOAD_UNSAVED_MESSAGE and NO_X_AXIS_VALUES_ERROR_MESSAGE, across 37
files.

The two Mantine `aria-label` defaults get a getter. Mantine merges
`defaultProps` by spread on each render, so the getter is read every time.

`ttag/no-module-declaration` now passes across all five trees it covers with
no opt-outs left, and `--report-unused-disable-directives` confirms none were
stranded.

* Stop spreads from flattening lazy translations

A spread or a rest destructure reads every getter it copies. Where an object
holds `get title()` so the string translates when it is read, spreading that
object at module scope translates it at import instead, which is the bug the
getters were meant to avoid.

Adds `mergeLazily` / `omitLazily`, which copy property descriptors instead,
and uses them where the flattening happened at import:

- `viz-core/lib/settings/utils.ts` flattened twice, once in the rest
  destructure and once in `...def`, so every setting built through
  `fieldSetting` / `metricSetting` / `dimensionSetting` froze at import.
- `palette/shortcuts/index.ts` spread five shortcut maps into one.
- `PaletteShortcutsModal` spread each shortcut again while grouping, so that
  now happens per render.

Measured with a test that imports every source module with `ttag` mocked:
40 modules and 370 translation calls at import, down to 35 and 256.

* Build parameter, admin and graph settings when read

* Build option lists when they are rendered

* Guard import-time translation with a test

* Make the instance analytics message a function so the plugin registry can replace it

* Resolve admin path names when the navbar renders them

* Give the enterprise caching tabs a lazy name too

* Skip generated cli-snippets from the side-effect scan

* Key the null slice by its untranslated label

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Fail the import-time guard on unlisted import errors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Read lazy translations once per render, not per lookup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Let jest transform the MCP ext-apps package

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

* Skip the SDK cli scripts in the import-time guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 14:16:04 +10:00
Romeo Van Snick
60f2668137 Build dashboard and query-runner questions from the store (#82241)
* Build dashboard and query-runner questions from the store

* Cover the pivot endpoint selection
2026-09-11 14:13:19 +10:00
Romeo Van Snick
503c3bf068 Build the card embed and SDK questions from the store (#82253)
* Build the card embed questions from the store

* Read the SDK parameter values off the question
2026-09-11 10:27:16 +10:00
Fraser
a6de42f467 Re-tier styled-components to U4 and enforce its shared tiers (#82256) 2026-09-11 12:22:50 +12:00
github-automation-metabase
c998859487 Auto-update documentation for master (#82298)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-10 23:37:23 +00:00
Alexander Polyankin
a491bd11be Namespace the table name BigQuery's compiled native form carries as :qp/table-name (#82275)
* Namespace the table name BigQuery's compiled native form carries as :qp/table-name

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Update the BigQuery compile tests for :qp/table-name

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Update the remaining BigQuery compile tests for :qp/table-name

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:23:06 -04:00
Anderson
0a30b63176 Trim redundant Data Studio TablePicker end-to-end tests (#81848)
Trim redundant Data Studio TablePicker E2Es
2026-09-10 19:18:50 -03:00
technomancy
f1ce332568 Refuse to create new tables in static datasets with Snowflake (#82222)
Refuse to create new tables in static datasets with Snowflake.

Table creation will always succeed on non-static datasets, but if it's
static then the table being created has to be a table that's in the
dataset definition.

Ensure all tests which alter the dataset by creating new tables
declare a non-static dataset. The transform-related tests switch over
to using transforms-dataset/transforms-test which most of them are
already using.

Upload tests have their dataset defined in a fixture so we can do it
all in one place instead of editing 60 different deftests.
2026-09-10 14:55:14 -07:00
Will Fry
1690028d0f GDGT-2653: uncomment E2E assertions (#82199) 2026-09-11 00:10:43 +03:00
Andrei Chernykh
0c76874681 Move SSO user provisioning a toggle card under server settings (#82232)
* Make SSO user provisioning a toggle card under server settings

* Debounce provisioning writes and drop the real timers from the spec
2026-09-10 13:52:45 -07:00
Chris Vandoodewaard
a6be073fb2 auto-merge e2e timing update prs (#82292)
* create PR using CLI, also auto-approve/merge

* cleanup string, check if things changed

* remove extra blank line

* namespace file
2026-09-10 15:05:29 -04:00
Ben Grabow
cb4541b965 Extract dispatch type hierarchy (#82226) 2026-09-10 13:37:56 -05:00
Ben Grabow
463fb9355b Extract entity types (#82265) 2026-09-10 13:23:50 -05:00
Andrei Chernykh
791b649cc0 bump qs to 6.16.0 (#82283) 2026-09-10 11:06:44 -07:00
Tyler Plude
085418edb8 [GIT-74095] Use git path separators in remote-sync export paths (#82284)
use git path separators in remote-sync export paths
2026-09-10 10:47:29 -07:00
Andrei Chernykh
1118c3e897 Bump js-yaml to 4.3.2 (#82282) 2026-09-10 10:36:08 -07:00
Alexander Polyankin
6e74e41eaf Strip undeclared keys from stored and legacy queries the way the request decoder does (#82273) 2026-09-10 13:32:50 -04:00
metabase-bot[bot]
38404fd9df [chore] Update E2E test timings with recent data (#81995)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-09-10 17:26:53 +00:00
Chris Vandoodewaard
8a7cd79f47 Bump backend deps to resolve security vulnerabilities (#82261)
* bump backend deps

* missed bump
2026-09-10 13:25:18 -04:00
Alex Yarosh
f3685652d3 docs: making semantic layer happen - org part (#82247)
* rename directories

* Revert "rename directories"

This reverts commit 13ebd4ae48.

* heard you like links

* what good is claude if it doesn't even find all the links

* Optimized 55 images, saved 698.8 KB (11.2% smaller)

* Fix docs links after merge

Co-authored-by: alexyarosh <16640109+alexyarosh@users.noreply.github.com>

* Fix merged docs regressions

Co-authored-by: alexyarosh <16640109+alexyarosh@users.noreply.github.com>

* Tighten merged docs links

Co-authored-by: alexyarosh <16640109+alexyarosh@users.noreply.github.com>

* Revert extra post-merge docs edits

Co-authored-by: alexyarosh <16640109+alexyarosh@users.noreply.github.com>

* link

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: alexyarosh <16640109+alexyarosh@users.noreply.github.com>
2026-09-10 12:49:43 -04:00
Andrei Simionescu
08497ded2f Bump csv-parse to 7.0.2 (#82266) 2026-09-10 15:48:24 +00:00
appleby
bfe0b19738 Update map tile server settings with suggested copy from DOC-301 (#81835)
* Update map tile server settings with suggested copy from DOC-301

And also include a link to the relevant page in the metabase docs.

* Add a note about not including private keys to the docstring for map-tile-server-url

so that the warning also shows up in the env var docs.
2026-09-10 08:44:01 -07:00
Riley Thompson
a155c1f4ee Document bigquery median behaviour (#82201)
* BigQuery: use PERCENTILE_CONT for median and percentile

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* update docs

* update doc

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:42:36 -04:00
Braden Shepherdson
9f48230b57 [PDFs] Respect the text wrapping settings on tables (#77090)
Fixes UXW-4714.

The static viz for tables uses a fixed width, but it was too big for the
PDFs, causing tables with long text in columns to lay out badly.

This PR disables the fixed width of `780px` on tables for PDFs, so that
the tables now lay out correctly.
2026-09-10 15:39:28 +00:00
Ben Grabow
503349c6ae Fix duplicates of the same namespace name (#81974)
* Fix collisions on metabase.notification.payload.core-test namespace name. Lint for future collisions.

* Include .clj-kondo/{src,test} in backend check triggers

* Expect the new project test namespace

* Symbolically reference project test suite namespaces

* Include resources, test_resources, test_config dirs in project checks

* Return [namespace relative-path] pairs.
Skip local/src

* Catch read-file errors and explain why some files may not have ns forms

* Sort paths that collide
2026-09-10 10:24:17 -05:00
Jeff Bruemmer
4d60c4bcf5 docs - nav link check (#82211) 2026-09-10 15:08:17 +00:00
Chris Truter
d9f905b7da Fix H2 migration failures after running transforms (#82135)
* Copy the transform tables in dump-to-h2 and load-from-h2

None of the nine transform_* models were in copy/entities, but metabase_table is,
and it carries a transform_id FK to transform. A table row reached the target
referencing a transform row that never arrived, and the deferred FK check
rejected it at commit.

transform_run_cancelation also goes in entities-without-autoinc-ids, since its
primary key is run_id rather than an autoincrement id.

Adds copied-tables-have-copied-fk-parents-test, which migrates a throwaway H2
database and fails if any copied table's FK parent is not copied.

* Simplify the copy foreign-key regression test

* Clarify the foreign-key coverage test names
2026-09-10 14:42:03 +00:00
Andrei Simionescu
36f273d523 BOT-1790: Redact Metabot chain-of-thought details in embedded sessions (#81872) 2026-09-10 14:01:55 +01:00
Alexander Polyankin
38cda45f69 Type every :any and [:map-of :keyword ...] property in the API (#82196)
* Make defendpoint reject request schemas that reach an open map, a keyword-keyed map-of or :any

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Close the MBQL 5 query and column schemas and declare the keys the query processor adds

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Type the shared schemas requests reach and drop ms/Map and ms/JSONString

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Close every map schema a request can reach

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Regenerate the modules config

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep action parameters string-keyed and type lens params and permission graph ids

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Declare the sandbox coercion keys a column's options can carry

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Declare the alias-info and Mongo keys a column can carry and strip internal keys from legacy column metadata

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Carry the Mongo column keys in the serialize test fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Declare the keys the frontend and query processor put on columns, parameters and card embeds

- column metadata: :remapping, a typed :lib/options, the nest-breakouts option key
- query parameters accept the rest of a stored filter declaration
  (sectionId, isMultiSelect, values source config etc.)
- static-list values may be one-tuples; raw-value template tags may
  carry a nil widget-type
- card embed attrs declare chart_href, child_target_id and host_data;
  explore filter refs accept the legacy field_ref the chart column has
- notification card echoes a metric's query_description
- serialization deep-sort orders mixed keyword/string keys
- viz-settings column refs are normalized before matching; a Field
  hydrated with its table can be fingerprinted; broken segment
  definitions read back as nil
- test fixtures: real fingerprints, snippet tag type, http channel
  details, comment content, no min_duration_ms on duration strategies

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Wire the defendpoint check, keep every map single-keyed, and apply the review findings

- defendpoint now calls closed-schemas/check! on every request schema;
  the walker no longer lets :any leak through collections, treats
  map?/:some as :any, skips :=/:enum/:fn values and never skips the
  default entry
- no map mixes keyword and string keys: ms/string-keyed-object gives an
  opaque JSON object string-keyed declared entries; ProseMirror attrs
  (documents, comments, explorations embeds), CSV upload parts, search
  weights and testing query specs use it and their readers use string
  keys; the serdes deep-sort comparator is back to master
- the Metabot context is fully typed from the frontend types instead of
  an opaque default; a draft transform query without a database is
  accepted
- permission graphs, settings PUT and SCIM PATCH reject a body whose
  map-of entries the decoder dropped; ad-hoc query endpoints drop a
  client-sent cache strategy; action parameter values keep collections
  out without nil-ing them; SCIM meta.resourceType is optional; sandbox
  user attributes are LoginAttributes; a metric filter clause carries its
  offset options; explore filter refs are the legacy field_ref the
  chart column has; result_metadata written by older releases and
  hasVariableTemplateTagTarget on parameters are declared; a bare
  map-of on a request path is guarded against scalars

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Close the endpoints tests define and let a column's target and name field be an id reference

The check runs on every defendpoint, the ones test namespaces define included. A column's
:target and :name-field are hydrated Fields on the backend but id references in the
metadata the frontend hands over; the closed column schema nil'd the reference, which
lost the search field of a model's primary key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Hand an action's query only the parameter values, and fix two fixtures the closed schemas caught

A query action's stored parameters carry the frontend's widget settings; the query gets
the value keys. The Metabot viewing-context tests use ids no card can have, and the
Mongo cache test spells the strategy key the schema does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Declare the table name BigQuery's compiled native form carries on a native stage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep a BigQuery RECORD's sub-field sync metadata off its result column, and fix a binning fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 08:17:08 -04:00
Timofey Kachalov
fc18a66b6c Add remaining data apps sandboxing e2e (#82170) 2026-09-10 15:44:38 +04:00
Chris Vandoodewaard
433561ad5b Move Snyk scans into the Lint Security workflow (#82197)
* Move Snyk scans into the Lint Security workflow

Fold snyk.yml's three jobs into lint.security.yml as snyk-generate-poms,
snyk-frontend-projects, and snyk-maven-projects, and delete snyk.yml.

Snyk now runs on the existing daily 11:00 UTC cron instead of its own
05:00 cron and dependency-file push trigger. The notify job reports Snyk
failures to Slack alongside CodeQL and Trivy, and Snyk's upload-sarif is
bumped from v3 to v4 to match the rest of the workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* add timeouts

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:16:23 -04:00
Will Fry
3c23f921e2 GDGT-3132 fix misaligned undo toast (#82045)
* GDGT-3132 fix misaligned undo toast

* migrate UndoListing to from styled components to CSS module

* absolute px -> rem, compact -> compact-md

* fix inconsistent toast height, use line-height instead of measuring the DOM to handle single vs multi line layouts

* strip comments
2026-09-10 11:06:19 +00:00
Oleh K
7f4883cc49 Fix inefficiencies in formatValue (#82103)
* GDGT-3195: Fix inefficiencies in formatValue

formatValue is fine per-cell but pathological in bulk; the "Copy results"
button made this visible, with a 100x2000 grid spending ~4.5s in this path.

- Drop a dead dayjs strict parse. The format literal 'T' is not valid dayjs
  escaping (dayjs wants [T]), so isValid() returned false for every value.
  It sat above the string/number/boolean branches, so every non-date cell
  paid for it.
- Only JSON.parse the value when a markdown_template will consume it,
  instead of on every call.
- Memoize the per-column type predicates (isURL/isEmail/isTime/isDate/
  isNumber/isCoordinate/isBoolean) in a WeakMap keyed on the column. Each
  fans out into recursive type-hierarchy lookups that cross into CLJS, and
  they only depend on the column. Entries snapshot the type fields they
  were computed from, since the custom-viz API hands third-party code
  mutable column objects.
- Skip new URL() construction for values with no ":" in getUrlProtocol.
- Cache Intl.NumberFormat by its constructor options, bounded at 100
  entries with oldest-first eviction.

Also make the value-formatting signatures honest: formatUrl, getLinkText,
getLinkUrl and getRemappedValue take unknown, which is what they actually
receive. getUrlProtocol keeps its string signature, with getLinkUrl
narrowing before the call. This removes both `value as string` casts.

Benchmarked with a synthetic 100x2000 copy path (medians of 5):
full table 4512ms -> 440ms, text 1231 -> 12, currency 637 -> 20,
integer 535 -> 15, boolean 343 -> 5, email 101 -> 4.

Claude-Session: https://claude.ai/code/session_01KUcocQj2bJPwVBi4dXKyEP

* Cleanup

* Revert the createPlainCellFormatter change

Hoisting the options spread out of the per-cell closure is not worth the
churn. Measured in isolation with a realistic 22-key columnSettings, the
spread costs 0.023us per cell against 0.002us to reuse a hoisted
reference, so it saves ~4ms per 200k cells — a rounding error next to the
dead dayjs parse at ~13.5us per cell, and nothing at all for columns with
click behaviour, where both shapes spread per cell anyway.

* Address review feedback

Guard the markdown_template JSON.parse with a typeof check instead of
carrying the placeholder cast comment.

Cover the two changes the review called out as untested:

- numberFormatterForOptions returns the same formatter for equivalent
  options, a separate one for different options, and evicts the oldest
  entry once the cache is full. The eviction test resets the module so
  the 100-entry boundary does not depend on what earlier tests cached.
- formatUrl passes strings and numbers through untouched and renders
  booleans and objects rather than returning them raw.

Both were checked by mutation: breaking the coercion and stubbing the
cache lookup to always miss fails exactly those assertions.
2026-09-10 12:49:27 +02:00
Fraser
667d038d46 Move the upload error modal slot into status (#82242) 2026-09-10 21:18:59 +12:00
Fraser
19df422bbd Move the storage setup provider slot beside its context (#82243) 2026-09-10 17:44:42 +12:00
Fraser
9fff12013e Move the DataReference library pane slot into querying (#81858)
* Move the DataReference library pane slot into querying

DataReference is the slot's only reader and querying owns its props type, so the slot lives next to it as PLUGIN_DATA_REFERENCE.LibraryPane.
The enterprise library plugin registers into it at boot, and plugins/oss/library.ts no longer imports querying's DataReference types.
2026-09-10 17:44:28 +12:00
Fraser
4bc6ccb00c Move the verified search filter slot into search (#81860)
* Move the verified search filter slot into search

SearchSidebar is the only reader of PLUGIN_CONTENT_VERIFICATION.VerifiedFilter, so the field becomes PLUGIN_SEARCH_FILTERS in metabase/search/plugin, next to the other search filter components. The EE content verification plugin registers the filter there from initializePlugin, and the rest of PLUGIN_CONTENT_VERIFICATION stays in plugins without the search import.

* Declare the search filters slot with definePluginSlot

* Import definePluginSlot from the plugins entry point

* Describe the verified filter default without naming its consumer

* Cover enterprise feature gates and plugin reset behaviour

* Remove redundant plugin registration tests
2026-09-10 17:44:12 +12:00
Fraser
81049d6069 Move the entity icon slot into hooks (#81857)
* Move the useGetIcon plugin slot into hooks/use-icon

The hook is the slot's only reader, so the slot and its OSS default live next to it as PLUGIN_ICON.
The enterprise collections plugin registers into it at boot, and plugins/oss/collections.ts no longer imports metabase/hooks/use-icon.

* Declare the entity icon slot with definePluginSlot

* Import definePluginSlot from the plugins entry point

* Cover enterprise feature gates and plugin reset behaviour

* Remove redundant plugin registration tests
2026-09-10 17:43:59 +12:00
Fraser
85ce065045 Remove the stale dashboard selectors side-effect entry (#82252) 2026-09-10 17:43:34 +12:00
Romeo Van Snick
dcb85ae924 Trigger the stats workflows from Run tests, gated on the uberjar artifact (#82244)
* Trigger the stats workflows from Run tests, gated on the uberjar artifact

Both workflows have recorded nothing since 2026-09-03. PR #81238 took the push
trigger off `uberjar.yml`, so master merges reach it only through
`workflow_call`. A reusable workflow reached that way raises no `workflow_run`
event, and both watchers went quiet. 143 master commits went unmeasured.

They now watch "Run tests", which is what builds the jar on a master merge.
Its conclusion is not a usable gate, because it is `failure` on master more
often than `success`, from jobs that have nothing to do with the jar. The
uberjar artifact is the right gate: a new job looks it up without downloading
it, and a docs-only merge that skipped the build finds nothing and stops.

Calling both from `run-tests.yml` would have been lower latency, but a stats
failure would then turn the master run red, and `rerun-workflows.yml` watches
that run and would re-run master CI over it. Keeping them in their own run
keeps a stats problem to itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Let a PR run the size benchmark against its own uberjar

A `workflow_run` workflow always runs the copy on the default branch, so a
change here cannot be tried out before it merges. This matches the trigger
Bundle Load Stats already carries. A called run measures and reports; the
upload step stays gated on the `workflow_run` path, so it writes no row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Trim the trigger comments in the stats workflows

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:38:31 +10:00
Romeo Van Snick
3403f82c0a Build the dashcard question and metric series from the store (#82250) 2026-09-10 14:32:27 +10:00
Romeo Van Snick
95306c12fc Take metadata options at the store door (#82249)
* Take metadata options at the store door

* Build the conversation detail questions from the store

* Select the test fixtures through the store door
2026-09-10 14:32:17 +10:00
github-automation-metabase
5d1e1ad8f1 Auto-update documentation for master (#82227)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-10 00:06:10 -04:00
Romeo Van Snick
6465693b78 Take a metadata provider where the database is already known (#82160)
* Take a metadata provider where the database is already known

* Fold the source-code provider into a hook
2026-09-10 13:37:15 +10:00
Romeo Van Snick
4e4cbac853 Give the visualizations a question builder instead of the metadata object (#82158)
* Give the visualizations a question builder instead of the metadata object

* Let the visualizations read the question builder themselves

* Read the store when a chart brush builds its question

* Drop the single-use question helper

* Give the map and object-detail views the question builder too

* Drop the last v1 metadata reads from the visualization tree
2026-09-10 13:37:10 +10:00
Romeo Van Snick
9762d0e164 Keep the CSP nonce out of the DOM, and off script-src (#82161)
* Give a script-src nonce only to responses that opt in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEcoNcbcoVpvPsGsX3ceua

* Hand the CSP nonce to page JS via the script element, not a DOM-readable JSON block

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEcoNcbcoVpvPsGsX3ceua

* Pass the new script-nonce? arg at the common-test CSP call site

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCoSa7L7KrXh42rDRYR3Nk

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:01:23 +10:00
Anderson
13e00d55b9 Apply responsive spacing and typography to dashboard chart cards (#81906)
* Apply responsive spacing and typography to dashboard chart cards (UXW-5181)

* Update Loki Snapshots

* Fix preview height regression and update e2e tests for dashcard size tiers

* Fix card heading typography and non-cartesian chart bottom padding

* Update Loki Snapshots

* Apply new styles only on cartesian charts

* Move changed styled components to css modules

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-09 23:51:25 -03:00
Romeo Van Snick
ad2d269251 Send the Field columns a parameter widget reads, and stop re-resolving them (#81936)
* Send the Field columns a parameter widget reads in param_fields

* Stop re-resolving a parameter widget's fields through the store

* Keep the empty default a parameter widget's fields prop had

* Read the driver-dependent Field columns in param_fields expectations
2026-09-10 12:04:48 +10:00
Romeo Van Snick
a22958cee3 Track page load times on every master merge (#80978)
* Add a harness for measuring bundle load time

* Report the second load and make the benchmark safe to run in CI

* Run the load benchmark across network and CPU conditions

* Sign the load benchmark in to a real instance

* Track page load times on every master merge

* Backfill load times for recent master commits

* Rewrite the load-time backfill in TypeScript with a tested core

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Launch the benchmark jar the way we ship it, and lint the .ts scripts

* Measure the whole load, not just DOMContentLoaded

* Do not let the performance marks gate a reading

* Drop the backfill workflow

The goal is a global trend, which the per-merge job builds on its own after a
few days. Backfilling only bought a readable chart sooner, at the cost of a
second workflow and the largest script in the PR.

The commit date stamp goes with it. It existed so a backfilled row could claim
its commit's date rather than today's, and the per-merge job never set it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Let a PR run the load benchmark against its own uberjar

A `workflow_run` workflow always runs the copy on the default branch, so a
change here cannot be tried out before it merges. The `workflow_call` trigger
lets CI hand the workflow a commit and reuse the uberjar it already built. A
called run measures and reports; it does not write rows to Stats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Report each state from one load, not a median per reading

Every reading was medianed on its own, so a row described a load that never
happened. It could also put the readings out of order and print a page-ready
that precedes the TTFB beside it.

`measure.js` now picks the run whose DOMContentLoaded is the median of its
series and reports that run whole. The second visit happens once per browser
profile, so it stays the run itself, and its page-ready now comes from that same
load rather than from a median that included the series' first, uncached run.

`Cold spread %` still comes from all the cold runs. A spread is a property of the
series rather than of one load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Read the largest paint with an observer, and cut the timeout

The first CI run reported a zero largest paint in all four conditions, and would
have done so forever. These entries never reach the performance timeline, so
`getEntriesByType` cannot see them. Only a `PerformanceObserver` can, and it has
to exist before the paint. Paint entries are buffered, which is why
first-contentful-paint needed none of this.

The browser keeps revising the value until the user interacts, and the reading is
taken once the page reports ready. It is a lower bound rather than the final
figure, which is what makes it comparable between commits.

That same run took 4.4 minutes, of which the measurement was 164 seconds. The
timeout drops from 25 minutes to 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk

* Move the bench to frontend/test and write it in TypeScript

---------

Co-authored-by: Fraser <frasermcintosh1@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 11:42:14 +10:00
Fraser
e92dfab50e Refine TypeScript authoring and review skills (#82141) 2026-09-10 13:39:46 +12:00
Romeo Van Snick
7c4159eedd Read the query builder's cards and tables without the metadata object (#82159)
* Read the query builder's cards and tables without the metadata object

* Build the query builder's question from the store's builder
2026-09-10 11:19:39 +10:00
Romeo Van Snick
6dc53c3773 Keep the generated CLI snippets out of the side-effect registry (#82172) 2026-09-10 11:18:18 +10:00
Romeo Van Snick
86d1bf3ec7 Read table foreign keys from the store, not the metadata wrapper (#82163) 2026-09-10 11:17:59 +10:00
Chris Vandoodewaard
fe4e2beffa only re-run the failed specs for SDK component tests (#82220)
* granular re-run for sdk component tests

* update e2e-test to avoid clobering artifacts on multiple attempts
2026-09-10 12:27:57 +12:00
Romeo Van Snick
e096a361d8 Take a metadata provider in the detail-view query helpers (#82151)
* Take a metadata provider in the detail-view query helpers

* Select the detail-view queries instead of building them

* Take the provider for the table's own database

* Guard the table query selector's memoisation

* Type the table query guard against the API table
2026-09-10 09:13:56 +10:00
Ben Grabow
8b58b48375 Test registration fixes (#82130)
* Add missing requires

* Add missing requires

* Apply batched suggestions from code review

Co-authored-by: Ben Grabow <ben.grabow@metabase.com>

* Sort nses

* */keep-me
2026-09-09 17:28:31 -05:00
Fraser
ecde907aff Add definePluginSlot and a reset registry to metabase/plugins (#81926) 2026-09-10 10:01:23 +12:00
Andrei Chernykh
d1934fb897 Fix the JWT settings spec import after the store mocks move (#82228) 2026-09-09 14:45:58 -07:00
Jeff Bruemmer
03a39f47c8 docs - CLI updates (#81978) 2026-09-09 20:54:06 +00:00
thackervishal
ad22a8730c white_label - do not use hardcoded metabase in filenames for nonOSS (#81979)
* for EE/white_label - do not use hardcoded metabase in filenames
* test coverage includes test for special characters in application-name
* also use gibberish names in tests
2026-09-09 16:00:18 -04:00
Andrei Chernykh
3d145ccb73 Redesign the JWT group mapping section (#81928)
* Redesign the JWT group mapping section

* Tidy the JWT group mapping tests

* Type the group mapping hooks explicitly and tidy comments
2026-09-09 12:27:15 -07:00
metamben
1b93876984 Prevent inactive fields from breaking chain filtering (#82041)
Closes QUE2-820
Closes #80557

There are two changes:
1. handle `nil` nodes gracefully during traversal, and
2. don't add `nil` nodes to the graph in the first place.

Both of these changes alone fix the reported issue, but both make sense on their own.
2026-09-09 18:39:26 +00:00
Anderson
283b194a38 Stabilize Data Studio bulk-table e2e via shared TablePicker expand helpers (#81623)
* Stabilize Data Studio bulk-table e2e via shared TablePicker expand helpers

* Match unnamed-schema tables fetch and trim expand wait timeout to 25s
2026-09-09 15:33:00 -03:00
Alexander Polyankin
28b2ad9eb6 Remove Honey SQL arguments from db.clj (#82175) 2026-09-09 13:34:39 -04:00
Andrei Chernykh
5b705231d7 Collection filters UX followup (#79863)
* Stabilize collection type-filter options via an items-metadata endpoint, default the type checkboxes to unchecked, and hide the filters for collections of 10 or fewer items

* Show every type in the filter popover, disabling the ones without matching items

* Use kebab-case params on the metadata endpoint and adapt toolbar tests to the visibility threshold

* Scope the items metadata to the models the list shows, name its count total_items, and align root permissions with the items list

* Assert the restricted root metadata count against the items list instead of a fixture-dependent number

* Code review improvements

* Cleaned up kebob usage

* Adapt the branch to master's show-exploration-documents param and re-record kondo budgets

* Cover the items-metadata endpoint's exploration-document scoping

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:20:19 -07:00
Alex Yarosh
10f53bdcec Fix documentation URL for collection permissions (#82136) 2026-09-09 11:45:02 -04:00
Paolo Rascunà
2e1a0c1a2c BOT-1895: Stream reasoning tokens for 7 more LLM providers (#81956)
Every provider behind Metabot now streams reasoning to the chain-of-thought UI,
with one predicate per adapter deciding the settings gate, the request directive,
stream forwarding and replay together.

* BOT-1895: Shared groundwork. Drop the pre-registry saved-model fallback from
  Azure and Google; make tool_calls outrank reasoning in delta classification,
  which previously lost a tool call's only id/name chunk; consolidate
  reasoning-part coalescing into one helper.

* BOT-2027: Bedrock. Request thinking per model via the shared builders. The
  openai-family gate answers false — probes across all four GPT models confirm
  Bedrock never emits reasoning summaries.

* BOT-2028: Azure. Gate derived from the admin-typed deployment name, matched
  case-insensitively, which also fixes a latent temperature 400 on cased Claude
  deployments. Negative paths verified live; the positive path rests on docs.

* BOT-2025: Z.AI. Forward reasoning_content and send the thinking directive
  explicitly — GLM was already thinking invisibly on every chat. Thinking-only
  models get reasoning_effort and a 2048 floor on forced tool calls, flagged on
  the supported-models row so the sets cannot diverge.

* BOT-2024: OpenRouter. Forward flat delta.reasoning and classify all 26 models
  by live probe; the gpt-5.5/5.6 family streams under the server default, where
  an explicit enable verifiably suppresses it. Forced tool calls floored on
  mandatory-reasoning models.

* BOT-2026: Google Gemini. One catalog drives context windows, directive and
  gate. Thought summaries on chat, thinkingLevel LOW on structured (no off
  switch), and a 2048 floor — probed, a real prompt at 512 spent the whole
  budget thinking and returned no tool call.

* BOT-2030: Mistral. Net-new cost: the default sends no thinking and only
  "high"/"none" are accepted. Flattens vector content chunks into the shared
  xf's events, and adds the opt-in reasoning replay hook Mistral's docs require.

* BOT-2046: Moonshot. kimi-k3 always thinks — explicit effort, mandatory in-turn
  replay, 2048 floor; kimi-k2.6 is optional and disabled under a forced tool
  choice, which it rejects while thinking.

Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:42:56 +02:00
Chris Vandoodewaard
e49b98186b remove quarantine-engine paramater from test-driver action (#82107)
* remove quarantine-engine param from test-driver action

* remove uses of quarantine-engine param

* add timeout
2026-09-09 11:13:42 -04:00
Nicolò Pretto
f74d32c39a make sdk analytics use specific client names for data app and mcp apps (#82110) 2026-09-09 17:06:12 +02:00
Anderson
6431bc412b Fix chart color settings not applying in the query builder sidebar (#82140) 2026-09-09 11:45:11 -03:00
Tyler Plude
40a15bc485 [GIT-81733] Handle jar native filesystem paths (#81927)
* return a native filesystem path from get-jar-path

* extract code-location->path and add jimfs-backed regression test
2026-09-09 15:39:32 +01:00
Ben Grabow
b0c8103cdb Make batch timing stubs more reliable (#82073) 2026-09-09 09:30:16 -05:00
metamben
4b88b2ae79 Make sure we provide an order by clause when paging with SQL Server (#82039)
Closes QUE2-841
Closes #81988
2026-09-09 16:49:24 +03:00
Nathan Voxland
d3c7576ccb Validate local infile (#81967) 2026-09-09 08:41:57 -05:00
github-automation-metabase
7dbf078a40 Auto-update documentation for master (#82133)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-09 09:32:21 -04:00
Jakub Chodorowicz
c0949b1d38 Gdgt 2465 popover (#81561) 2026-09-09 15:26:04 +02:00
Romeo Van Snick
ebdcd6c743 Take a metadata provider in the shared query helpers (#82150)
* Take a metadata provider in the shared query helpers

* Select the segment query instead of building it

* Take the provider for the segment definition's database

* Guard the segment query selector's memoisation

* Guard the segment query assertion against a vacuous pass
2026-09-09 22:05:52 +10:00
Andrei Simionescu
66038ba6d0 BOT-1427: link conversation user groups to filtered usage stats (#81869)
* BOT-1427: link conversation user groups to filtered usage stats

* Take the group id as an argument to the usage URL helper
2026-09-09 12:48:52 +01:00
Romeo Van Snick
97d871c78a Build the new metric page's queries from the store's builder (#82153)
* Build the new metric page's queries from the store's builder

* Select the new metric page's queries instead of building them
2026-09-09 20:57:50 +10:00
Romeo Van Snick
6bb98bce1d Build action-creator questions from the store's builders (#82154)
* Build action-creator questions from the store's builders

* Select the action-creator question instead of building it
2026-09-09 20:21:30 +10:00
Romeo Van Snick
c908146299 Build the browse-tables virtual question from the store's builder (#82155)
* Build the browse-tables virtual question from the store's builder

* Select the browse-tables url instead of building it
2026-09-09 20:20:56 +10:00
Romeo Van Snick
2d80c0e360 Build the model and source-card questions from the card the loader fetched (#82156) 2026-09-09 20:20:25 +10:00
Romeo Van Snick
7f589be657 Build draft questions from the store's builder (#82144)
* Build draft questions from the store's builder

* Read the store when normalizing a transform source
2026-09-09 20:18:36 +10:00
Kamil Mielnik
12e5f16918 GDGT-2796 Namespacing custom viz settings (#81181) 2026-09-09 16:38:51 +07:00
Egor Grushin
979b344073 Fix light theme border-highlight token (#82157)
Fix light theme border-highlight: brand[30] -> brand[60] (GDGT-2484)
2026-09-09 11:30:55 +02:00
Romeo Van Snick
4558ce654d Narrow the dashboard navigation cards, and build their questions from the store (#82148) 2026-09-09 19:11:28 +10:00
Romeo Van Snick
e584e95d48 Drop the question the click-behavior source filters never read (#82149) 2026-09-09 16:50:23 +10:00
Romeo Van Snick
6ed9837a03 Build parameter fields from param_fields, not the metadata store (#81935)
* Build parameter fields from param_fields, not the metadata store

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

* Answer a field's remapped value from the store, not a v1 method

The parameter pipeline now carries plain API fields, which have no
`hasRemappedValue` or `remappedValue`. Both callers read the store
through one selector instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

* Carry an FK's target in param_fields for anonymous requests

The usual :target hydration reads the target Field through permissions,
which a public or embedded request does not have, so a public parameter
widget could not label an FK's values.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

* Give the number filter story a field with no values to list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:17:40 +10:00
Romeo Van Snick
e4cc4a61ff Reduce the frontend to one memoize helper, and stop top-level caches leaking (#81866)
* Add a jest project for heap retention specs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Scope the parsed x value cache to one getXValues pass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Assert heap retention on the settled pass, not the first touch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Key the log timestamp cache on the log object

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Key the schema viewer flow graph on the response object

The memo key serialized every table and field of the schema, so each distinct
schema held a megabyte-scale string forever. RTK Query returns a stable
reference per cache entry, so a WeakMap keeps the identity that useGraphSync
depends on without retaining the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Key pie.rows on the dataset instead of a JSON copy of it

The memo key serialized every row and column, so each dataset the user viewed
held roughly its own size in cache key for the life of the tab. A WeakMap on
the dataset with a short settings subkey keeps the same hit rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Ban new uses of underscore's memoize

The 17 remaining call sites are allowlisted. The list may only shrink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Narrow the memoize rule to module scope

A cache built inside a function, a component render, a class field or a
constructor is released with whatever owns it. Only a module scope cache lives
for the life of the tab, so the rule now flags just those.

This drops the allowlist from 17 files to 5, and each remaining entry carries
the reason its set of keys stays small.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Remove the memoize allowlist

Three of the five module scope caches are gone:

- getScrollBarSize takes no arguments, so it is a lazy value, not a cache.
- getScheduleExplanation returns a string that no caller compares by identity.
- cronToBuilderValue moves to the one caller that needs a stable identity.
  Schedule takes its value as a useMemo dependency, so StrategyForm now holds
  that memo itself. A user can write any number of cron strings, so a module
  cache for them was unbounded.

The remaining two carry an eslint-disable with the reason their set of keys
stays small.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Follow renderer_utils into the viz-core module

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Cover metabase/utils/memoize in the module scope rule

That memoize roots its WeakMap on the function it wraps, so it only releases
once that function does. At module scope the function never does, and the
nested Maps hold every argument ever passed. No call site does this today, but
the rule could not have caught one.

The rule now covers both sources, so it drops "underscore" from its name.
memoizeClass stays allowed: it keys on the instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop re-reselect from the dashboard selectors

createCachedSelector keyed getQuestionByCard and
getDashcardParameterMappingOptions on the card id, a primitive, in a
FlatObjectCache. That cache is a plain object with no eviction, so a session
kept one Question per card id it ever saw, and each Question pins the whole
Metadata snapshot it was built with.

reselect v5 defaults to weakMapMemoize, which keys on the card and the metadata
objects themselves. Entries are released when their card leaves the store or
the metadata is replaced, and identity still holds for every card in the
working set, which is what connect and useSelector compare on.

Measured over 300 cards, with the states dropped: 6.2 MB retained before,
0.0 MB after. Per entry, 21462 B before and 51 B after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop re-reselect from the lockfile

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Back metabase/utils/memoize with reselect's weakMapMemoize

The hand-rolled implementation had two problems. It allocated two arrays on
every call, including cache hits: 225 ms against 20 ms for weakMapMemoize over
two million hits. And its WeakMap was keyed on the wrapped function rather than
the wrapper, so a cache built around a module-level helper outlived the wrapper
that owned it. A plain Map would have been safer.

weakMapMemoize matches on the same terms, identity for objects and value for
primitives, so no call site changes. It also weak-keys object arguments, which
the old one held strongly.

definition-cache passed weakMapMemoize as the memoize and argsMemoize options.
reselect v5 already defaults to both, so those are dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Route all memoization through metabase/utils/memoize

underscore's memoize is gone from the app. Its 16 call sites now use the house
helper, which is reselect's weakMapMemoize. Three of them were in metabase-lib,
which cannot import metabase/utils, and all three took no arguments, so they
became plain lazy fields instead.

Two rules keep it that way:

- no-direct-memoize-import bans memoize from underscore and weakMapMemoize or
  lruMemoize from reselect, in any scope. Only metabase/utils/memoize may reach
  for an implementation.
- no-module-level-memoize keeps its job, now that the house helper is the only
  one left to police.

measureTextWidthSafely took a style object that was rebuilt on every render, so
identity keying would never have hit. Its third argument is now the font family
string, which is the only field any caller ever set.

useMemoizedCallback returns the callable shape rather than casting to T, since
the memoized function also carries clearCache and resultsCount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Build memoizeClass on the house memoize

It kept a module-level WeakMap and walked a nested Map trie keyed on the
instance, the method, the argument count and then each argument, assembling
that path into an array on every call.

Keying weakMapMemoize on the instance and then the arguments gives the same
per-instance caching. One cache per decorated method replaces the shared trie,
so the method no longer needs to be part of the key, and neither does the
argument count. The file is down to 61 lines from 128.

A leak spec covers the property this rests on: an instance's results are
released with the instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Measure memoizeClass retention under load

2,000 instances per pass, 20 calls each, across a method with no arguments and
one taking a primitive and an object. Nothing is retained once the instances
are dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Delete memoizeClass

It decorated a prototype method by rewriting the property descriptor, and put
the memoization 600 lines away from the method it applied to. Its one consumer
memoized a method whose body uses no `this`, so the same caching comes from
assigning a memoized arrow to a class field. The field is created per instance,
so the cache is released with the instance, and the memoization is now visible
at the definition.

metabase/utils/memoize is 14 lines, from 128.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Run the heap specs from their own jest config

They were a project inside jest.config.js, and a bare `jest` runs every
project, which is what CI does. So the sharded unit run picked them up, without
--expose-gc, and they failed as designed.

They now live in jest.memory.conf.js, the way the timezone specs live in their
own config. The shared base moves to jest.base.conf.js so neither file has to
duplicate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reduce the heap specs to one collection assertion each

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Follow the store mocks into test support

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:02:13 +10:00
Alexander Polyankin
c1805286bb Convert db.clj public functions to mu/defn with schemas (#82139) 2026-09-09 04:47:13 +00:00
Chris Truter
22b2577799 Require credentials when changing LLM provider base URLs (#81450)
A settings manager could change a connection's base URL without entering its
masked secret again. The update merged the stored secret back in, and
credential verification sent it to the new host before anything was persisted.
A network policy cannot tell a legitimate provider from a public host the
caller controls.

Changing a connection's effective base URL now requires freshly submitted
credentials for every sensitive field that would otherwise carry a secret to
the new destination. Environment-owned credentials must be repointed through
deployment configuration. Trusted provisioning such as config.yml is
unchanged.

Generic writes to the llm-providers setting run the same field validators, so
PUT /api/setting cannot bypass the connection API's checks.
2026-09-09 06:33:41 +02:00
Romeo Van Snick
2a35f6390b Let the measure and segment query hooks reach the store themselves (#82152)
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:59:17 +10:00
Chris Truter
f508214657 Enforce a network policy on LLM provider base URLs (#81301)
* Enforce a network policy on LLM provider base URLs

A settings manager could point any provider's base URL at an internal
host; the model listing behind the admin Connect button then fetched
GET /v1/models from it with bare clj-http, and a 4xx body preview came
back in the error. Semi-blind SSRF.

New llm-allowed-networks setting, same shape as warehouse-allowed-networks:
external-only on Cloud, allow-all self-hosted (vLLM/Ollama on localhost
or a private network is the normal case there). Admin-only, so the
settings-manager class can't loosen it.

Every base-url registry field validates against it on write, so the
API rejects a forbidden URL before verify-credentials! makes the
request. core/request and llm.anthropic/chat-completion check again at
request time and resolve DNS through the policy resolver, which closes
the rebinding gap. The AI proxy URL is operator config and is exempt.

e2e pins MB_LLM_ALLOWED_NETWORKS=allow-all next to the warehouse pin,
since the mock LLM server is on localhost.

* Cover the embedding and Google token sinks too

Semantic search posts embeddings to llm-openai-api-base-url and to the
settings-manager-writable ee-embedding-service-base-url with bare
clj-http, so the same writer class reached the same kind of sink. Those
now run the llm-allowed-networks check and DNS resolver; the operator's
ai-service-base-url is exempt, and ee-embedding-service-base-url is
validated on write.

A Google service account key carries its own token_uri, and the
credential library posts to it to mint an access token before any
request we guard. Keys are now refused unless token_uri is one of
Google's OAuth hosts.

* Use var links in LLM policy docstrings

* Keep SSRF rejections out of the embedder breaker

* Normalize connection-time LLM policy errors

* Link LLM timeout settings in request docs

* Fix reflection warnings in LLM URL validation

* Use explicit policies for managed AI endpoints

* Address final Copilot review feedback

* Reject non-global special-purpose addresses

* Align network policy terminology

* Make external-only the LLM network policy everywhere

llm-allowed-networks is now environment-only (:internal, no setter)
and defaults to external-only on every instance. A settings manager is
who it defends against, and on Cloud a customer admin loosening it
would be reaching for our own infrastructure, so nobody sets it through
the API. Self-hosted operators with a vLLM server on a private network
opt in with MB_LLM_ALLOWED_NETWORKS; a value that is not one of the
three policies fails closed with one warning.

The rejection message says what to do: self-hosted, it names the
setting and which value fits; on Cloud, that private networks are out
of reach. The vLLM field's placeholder and help text no longer
advertise a private host as the default setup.

The managed endpoints' :allow-private is now a floor rather than a
replacement, so an explicit allow-all keeps an AI service on localhost
reachable. An embedding service URL supplied by the environment gets
the same floor, since the setter cannot vet it.

* Enforce the LLM network policy at connection time only

The request-time check resolved the host with a blocking lookup that
the connection's DNS resolver then repeated, and in the embedding path
it ran ahead of the breaker's fast-fail. Requests now run only the
I/O-free syntax check up front; the resolver decides on the addresses
the connection actually opens, and its refusal maps to the same 400
the set-time check gives. The set-time check still resolves, so a bad
URL is refused when it is entered.

One helper, llm-request-opts, replaces the three hand-copied
sequences; the refusal is thrown from one constructor. It carries
:status 400 beside :status-code because the semantic-search dead-letter
queue files errors by :status and was retrying a refused endpoint on
the fast schedule.

A base URL with a username or password is refused outright, and the
refusal names only the host, since the message reaches Metabot chat
users and the API's 400 body. A connection-time refusal is logged; it
is the only trace a host that rebinds leaves.

* Read llm-allowed-networks from the environment alone

get-value-of-type falls back to the application database, so a value
stored there before the setter went away kept working. The getter now
reads only the environment variable, and the setting carries a :doc
string so the generated environment-variable docs keep it now that it
has no setter. On Cloud the default applies; the operator's environment
is still honored, which the e2e runner's pin relies on.

* Disable redirects for LLM requests

* Normalize embedding service base URLs

* Normalize configured embedding URLs on read

* Clarify LLM network policy documentation

* Revert "Clarify LLM network policy documentation"

This reverts commit 8f2cff7c4963efba6756c117927ca56492cd49c5.

* Polish new LLM security comments

* Clarify embedding API key fallback

* Treat unallocated IPv6 space as non-global

* Grant private-network policy floors to environment-supplied URLs only

* Refuse IPv6 space IANA has not allocated

2000::/3 is the global-unicast space, but everything in it from 2d00:: up
is reserved: no ISP routes it, so a name resolving there is either a
mistake or an internal network squatting on it. `3000::1` passed as public
before.

* Pin Google's token endpoints by whole URI

The transport posts to whatever `token_uri` says, so checking only the
scheme and host let through another path, port, or query string on
accounts.google.com.

* Validate connections written as the llm-providers setting

The connection endpoints check a base URL against the network policy, but
the list they write is a setting in its own right: PUT /api/setting or a
config.yml entry saved a loopback URL straight past them. Run the field
validators in the setter too, on the connections a write changes.

* Hold the token-cache invalidation to the LLM network policy

/api/premium-features/token/refresh POSTs to ai-service-base-url with the
premium token in the URL path. That setting is admin-writable, so the
request needs the same syntax check, redirect ban and DNS policy as any
other LLM request; only an environment-supplied URL earns the private
floor.

* Check the target host when a JVM proxy is in the way

clj-http's route planner honours -Dhttps.proxyHost, so the connection is
opened to the proxy and the :dns-resolver is handed the proxy's hostname:
the target is resolved by the proxy, out of reach, and a private egress
proxy is itself refused under external-only. Treat an operator-configured
proxy as the trusted boundary it is and check the target host up front
instead.

* Judge a NAT64 address by the IPv4 it reaches

64:ff9b::/96 is globally reachable, but its low 32 bits are the IPv4 a
gateway translates to, so 64:ff9b::7f00:1 passed external-only and reached
127.0.0.1. RFC 6052 forbids the prefix carrying a non-global IPv4; nothing
on this side of the gateway enforces that.

* Validate only the connection fields a write changes

Comparing whole connections meant rotating an API key re-ran the base URL
check, so a private URL stored before the check existed made its own
connection unwritable.

* Name a network policy that would change the outcome

A refusal under a deployment endpoint's allow-private floor advised
setting MB_LLM_ALLOWED_NETWORKS=allow-private, which was already in force.
The resolver records the policy it refused under; use that for the message
and the log line.

* Refuse an unresolvable host behind a JVM proxy

Everywhere else a host that will not resolve is allowed, because the
connection then fails on its own. Behind a proxy there is no connection to
fail: the proxy resolves the target, so a split-horizon or cluster-internal
name went out with nothing checked at all.

* Keep proxy-only DNS working

* Document and test the LLM proxy enforcement boundary

* Match JVM proxy detection to Apache routing and fix tile fixture
2026-09-09 03:56:22 +00:00
Romeo Van Snick
a24ac528b3 Build reference questions from a provider and the field itself (#82142)
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:51:21 +10:00
Alexander Polyankin
e0c40554da Accept a plaintext numeric example-dashboard-id in BackfillExampleDashboardIdValueWithAad (#82143) 2026-09-08 22:03:55 -04:00
Romeo Van Snick
c9c2552964 Build SDK and document questions from the store's builder (#81867)
* Build query builder questions from the store's builder

* Build document questions from the store's builder

* Build SDK and document questions from the store's builder
2026-09-09 11:46:12 +10:00
Romeo Van Snick
b57475ab38 Build query builder questions from the store's builder (#81861)
* Build query builder questions from the store's builder

* Build document questions from the store's builder
2026-09-09 11:42:14 +10:00
Romeo Van Snick
758aade121 Take metadata from the store's own doors, not the raw Metadata object (#81847)
* Build queries from a provider, not the raw Metadata object

* Build questions from cards through the metadata store

* Type the dashcard menu against the question dashcards its callers pass

* Name the guard the callers actually use

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:10:30 +10:00
Romeo Van Snick
5db23f4182 Move the test-side store mocks into test support (#81499)
* Move the test-side store mocks into test support

* Drop the moved store mocks from the side-effect registry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 09:06:24 +10:00
Andrei Simionescu
0efc4c2161 BOT-1575: Show the illustrations toggle whenever illustrations are off (#81871)
The illustrations switch only appeared next to a custom icon, so turning illustrations off
and then removing the icon left no way to switch them back on. It now appears whenever
illustrations are off as well.
2026-09-08 23:40:41 +01:00
Andrei Simionescu
f4b04b6966 BOT-1573: Slackbot posts specific copy for known error codes (#81200)
Clackbot answered every failure with "Something went wrong", so a user denied by AI
permissions or an admin with a misconfigured provider had nothing to act on. Both the
streamed error part and the thrown-exception path now look the failure up by error
code and reply with copy that names the problem, and usage-limit errors pass their
server-authored message through. Anything unrecognised keeps the generic line, so raw
provider errors stay out of shared Slack channels.
2026-09-08 22:14:02 +00:00
Romeo Van Snick
83ed9b6c3d Serve the metabase-lib provider from the metadata store (#81664)
* Serve the metabase-lib provider from the metadata store

* Serve metric metadata providers from the store too

* Serve providers for metrics and for per-item database lookups

* Take a provider lookup in the transforms query helpers

* Resolve SDK query sources from the RTK cache, not the mirror

* Delete the unused isSourceEmpty helper and the dead tableId prop chain

* Canonicalise getMetadata's options so both call shapes share one Metadata

* Build v1 Questions through the metadata store (#81666)
2026-09-09 08:01:09 +10:00
Andrei Chernykh
0cd9134160 Keep closed group mapping dropdowns unmounted (#82124) 2026-09-08 14:56:21 -07:00
Sloan Sparger
d273dc56b6 [Metabot] Context window improvements (#81133) 2026-09-08 21:49:40 +00:00
Sloan Sparger
2d6db97c9d [Metabot] Frontend convo message shape refactor (#80967) 2026-09-08 16:10:26 -05:00
Ben Grabow
3fb5cbcb74 Use explicit hierarchy for action keywords (#79464)
* Use explicit hierarchy for action keywords

The :table.row/* -> :table.row/common and :data-grid.row/* -> :data-grid.row/common
relationships lived in Clojure's global hierarchy, sharing one mutable namespace with model
keywords, hook traits and every other subsystem. Move them into metabase.actions.hierarchy,
mirroring metabase.events.hierarchy: a defonce var holding a hierarchy map, mutated through
derive!.

Four multimethods consume those parents -- action-arg-map-schema, normalize-action-arg-map,
validate-inputs! and default-mapping -- and now dispatch on the new hierarchy. Driver-level
action methods are untouched: they dispatch on [driver action] through the driver hierarchy
and only ever name leaf action keywords.

The enterprise action_v2 module derives through metabase.actions.core, which re-exports
derive!.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* State the real reason driver action dispatch is unaffected

The hierarchy docstring said driver-level action methods are safe because they only ever name leaf action
keywords. That describes an incidental convention -- `[:postgres :model.row/create]` happens to be the only
such method -- and implies a fragile dependency that does not exist.

`driver/hierarchy` is its own `(make-hierarchy)`, and `isa?` against an explicit hierarchy never consults the
global one for keyword parents, so the derives this namespace moves had no effect on `prepare-query*` before
the move either. Say that instead, and note it at `prepare-query*` too, where someone adding a driver method
will actually be reading.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Truter <chris@metabase.com>
2026-09-08 21:06:29 +00:00
Alex Yarosh
f52a94984a docs: typos (#82079)
* typos

* Update docs/developers-guide/api-changelog.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

* fine

* Apply suggestions from code review

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-09-08 20:38:33 +00:00
Jeff Bruemmer
8170a2ecdd docs - nav updates (#82116)
nav updates
2026-09-08 20:23:55 +00:00
Ben Grabow
ca2c341522 Use explicit hierarchy for auth identity providers (#79463)
* Use explicit hierarchy for auth providers

Provider relationships (:provider/saml -> ::provider/provider, etc.) lived in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move them into metabase.auth-identity.hierarchy, mirroring
metabase.events.hierarchy: a defonce var holding a hierarchy map, mutated through derive!
and underive!, read through isa? and descendants.

The three provider multimethods (validate, authenticate, login!) now dispatch on that
hierarchy via methodical's :hierarchy option.

Callers outside the module go through metabase.auth-identity.core, which re-exports the four
operations; namespaces inside the module use metabase.auth-identity.hierarchy directly, since
core requires them.

derive! is whitelisted as :parallel/safe because each test registers its own distinct
:provider/test-* keyword, so the additions never overlap. The plain `derive` calls it replaces
were equally global but escaped the linter by not ending in `!`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Register test providers at namespace level, not inside parallel tests

Each of these eight ^:parallel tests registered its provider by calling derive! and
methodical/defmethod in the test body. Both mutate process-wide state, so the tests were never
actually parallel-safe -- only derive! was visible to the linter, because defmethod does not
end in `!`.

Hoist both registrations above their deftest, alongside the test providers already registered
at the top of the namespace. The deftests are now pure assertions over providers that exist by
the time the namespace has loaded, which is what ^:parallel claims. No linter exemption for
metabase.auth-identity.core/derive! is needed, so .clj-kondo/ratchets.edn is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Truter <chris@metabase.com>
2026-09-08 22:04:07 +02:00
Ben Grabow
3390d69763 Use explicit hierarchy for domain entity types (#79459)
Domain entity types derived from :DomainEntity/* in Clojure's global hierarchy, sharing one
mutable namespace with model keywords, hook traits and every other subsystem. Specs are loaded
from YAML files, so adding a spec file was writing into that shared space.

Move them into metabase.xrays.domain-entities.hierarchy, mirroring metabase.events.hierarchy:
a defonce var holding a hierarchy map, mutated through derive!. A leaf namespace rather than
an in-file var because both specs (which registers types and validates them) and core (which
ranks candidate entities by ancestor count) read it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:36:22 +00:00
Ben Grabow
adf3bd7254 Use explicit hierarchy for user-key-value namespaces (#79460)
Key-value namespaces registered by defnamespace derived from ::registered-namespace in
Clojure's global hierarchy, sharing one mutable namespace with model keywords, hook traits and
every other subsystem. Namespaces are loaded from EDN files on disk, so an operator adding a
file was writing into that shared space.

Move them into a hierarchy var private to this namespace, mutated through derive! -- the same
shape as metabase.events.hierarchy, kept in-file because defnamespace is the only writer and
known-namespaces the only reader.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:31:31 +00:00
Chris Vandoodewaard
2357c1b843 refactor pr env cleanup into single workflow (#81817)
* refactor pr env cleanup into single workflow

* add timeout

* rename to match naming scheme
2026-09-08 19:21:42 +00:00
Ben Grabow
e85fa9c446 Fix BigQuery tests (#82106)
* Avoid wildcard scan against all similar datasets on the warehouse

* Update bigquery page fetch tests to match new adaptive page sizing API
2026-09-08 14:08:28 -05:00
Nathan Voxland
250868257e Improve LLM token usage for add_reseach_group (#79556) 2026-09-08 14:08:26 -05:00
Jakub Chodorowicz
27752c76e0 Gdgt 3198 allow setting metabase instance url when starting embedding (#82063)
allow setting metabase instance url for embedding storybook
2026-09-08 20:38:18 +02:00
Alexander Polyankin
15106f17fd Add kondo rule keeping Toucan 2 query calls in module db namespaces (#82099) 2026-09-08 14:07:41 -04:00
Alexander Polyankin
4b2cfdc623 Add MB_DISABLE_LEGACY_STARTUP_ENCRYPTION to refuse startup instead of encrypting legacy plaintext (#82097) 2026-09-08 13:38:39 -04:00
Will Fry
3439faff23 GDGT-3094: remove attachment limit tooltip (#82055) 2026-09-08 17:49:35 +01:00
Chris Truter
18f216de52 Ensure mage -test runs every test namespace (#82051)
* Fail the mage suite when a test namespace is not required

`mage -test` runs `run-all-tests`, which only sees namespaces that are already
loaded, and loading happens solely through this namespace's `:require`. A test
file missing from it never runs, and nothing says so.

`mage.quick-test-runner-test` and `mage.start-db-test` were missing that way.
They are required by `mage/test/mage/runtests.clj`, but CI runs `mage -test`,
which does not read that file, so their four tests had not been running.

* Drop the dead mage test runner and a test that cannot fail

Nothing referenced mage/test/mage/runtests.clj. It required three namespaces
directly, which made mage.quick-test-runner-test and mage.start-db-test look
wired up while `mage -test` never loaded either.

mage.start-db-test asserted a string literal, so it passed unconditionally.
Wiring it in would have added a green test covering nothing. start-db still
deserves real tests; this makes their absence visible.
2026-09-08 18:37:44 +02:00
Anderson
e354134858 Show table publisher and publish date in the Library (#81076)
* Show table publishing info in Library

* address review comments

* Refine publishing info response

* Clarify publishing audit lookup

* Fix publishing info database queries
2026-09-08 12:56:46 -03:00
Ben Grabow
34da51c654 Fix OIDC test isolation (#81987)
Use the right localhost-site-url test helper, and make sure the web server is initialized
2026-09-08 10:47:40 -05:00
github-automation-metabase
55f3c0f268 Add docs/util/data/nav.yml for master (#82078)
Add docs/util/data/nav.yml (seeded from docs.metabase.github.io)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-08 11:34:19 -04:00
Sébastien
4cb99413ed Deflake the standalone cardEmbed supporting text test (#81772) 2026-09-08 15:58:27 +02:00
Ben Grabow
beb22cd1b5 Add missing fixtures init (#82007) 2026-09-08 08:44:03 -05:00
Voytek Pituła
eb7bcaa06f Add krever to team.json and github-slack-map.json (#82058)
Adds @krever to the Metabot team in .github/team.json and maps the
GitHub username to Slack ID U0BUSKQRN80 in release/github-slack-map.json.
2026-09-08 15:34:58 +02:00
github-automation-metabase
7e11715582 Update translations 2026-09-08 (#82056)
* Update translation sources 2026-09-08

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-09-08 13:20:14 +00:00
Stas Gavrylov
c7a5060ca8 GDGT-2462: Update Switch component (#81709)
* GDGT-2462: Update Switch component

* GDGT-2462: Add Switch Loki references

* Update Loki Snapshots

* Address PR findings

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-08 15:42:53 +03:00
Kevin Kelleher
b7500371ca Remove duplicate in Gauge color picker (#81920)
fix duplicate in gauge color picker
2026-09-08 12:41:00 +00:00
lbrdnk
bf0186730c Add goldens triggers (#81873)
* Enable golden scopes

* Add label triggers

* Temporarily remove label input

* drop the input completely

* Re-add label
2026-09-08 14:40:41 +02:00
Timofey Kachalov
6ca683c042 Re-enable Shoppy sample app e2e (#82061) 2026-09-08 16:27:53 +04:00
Sébastien
c16a8abfa6 Fix EditableDashboard collapsing when entering edit mode (#82019)
* Fix EditableDashboard collapsing when entering edit mode without a sized parent

* Also fix EditableDashboard collapsing while sharing

* Revert "Also fix EditableDashboard collapsing while sharing"

This reverts commit be2f86c738.
2026-09-08 11:28:56 +02:00
Oleksandr Yakushev
074e967be7 [liquibase] Remove obsolete proxy package hack (#81480) 2026-09-08 11:51:55 +03:00
Bartosz Rybacki
47bc44f775 GDGT-2028 Fix wrong column headers in alerts with a remapped foreign key (#81896)
GDGT-2028: Fix wrong column headers in alerts with a remapped foreign key

The header titles and the header row were built separately and zipped by
index. Only the header row dropped the remap target column, so every title
after it shifted by one. Both now derive from `visible-columns`.
2026-09-08 10:31:41 +02:00
Andrei Simionescu
06d46ec0c2 BOT-2113: Copy metabot_permissions on every edition
An H2 dump made by an OSS build kept the metabot_permissions rows that migration 061 seeds into the fresh target instead of copying the source's own rows, because the table only appears in cmd.copy/entities when the Enterprise backend is on the classpath. The seed references the four magic groups at ids 1 to 4, which on the source are often different groups or no groups at all, so restoring that dump into Cloud either failed on a foreign key or handed Metabot permissions to whoever happened to sit at ids 3 and 4.

The table is now copied on every edition. The load side also skips rows whose group is missing from the dump, so the dumps customers have already sent restore once Cloud picks this up, and magic groups left without rows get the 061 seed re-run by magic_group_type so a restored instance matches a fresh one. Grants that landed on a real custom group cannot be told apart from a deliberate grant and stay a data fix, tracked in CLO-5999.
2026-09-08 08:30:46 +00:00
Romeo Van Snick
28a72bb30c Answer field remapping from the field, not the metadata store (#81932)
* Answer field remapping from the field, not the metadata store

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

* Say what a remappable field has to carry, and simplify the format type

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:19:53 +10:00
Chris Truter
b57dc3f98e Prevent mage commands hanging on orphaned output pipes (#81699)
* Start subprocesses with ProcessBuilder

Runtime.exec's three-arg form has been deprecated since JDK 18, and it forced the command and environment
through String arrays with array type hints to match. ProcessBuilder takes both as collections, so the
call site is one line and the array juggling is gone. `:env` still replaces the parent environment rather
than adding to it, which is what the docstring promises: that is `.clear` then `.putAll`, not a bare
`.putAll`. read-lines now hints the BufferedReader the namespace already imports.

* Spend one timeout budget across a command's three waits

sh* waited for the exit code, then stdout, then stderr, giving each a fresh timeout-ms. A command that
dribbled output could take three times its budget, and the exception still claimed the single figure, so
"Timed out after 200 ms." could be thrown 600 ms in. The three waits now measure against one deadline
taken when the process starts.

* Measure the backend health-check wait with nanoTime

check-backend-health! polls for five minutes against a wall-clock start, so a clock adjustment mid-poll
could cut the wait short or stretch it. The interval literal is now written as a duration too.

* Kill the command however the wait ends, not only on timeout

Three fixes around sh*'s failure handling.

The catch was for ExceptionInfo carrying :timed-out?, so any other way out left the command running: a
reader future failing on the pipe, or the calling thread being interrupted. It now catches Throwable and
asks whether the process is still alive, which is the question that actually decides whether to kill.

:or does not fire on an explicit nil, so a caller computing :timeout-ms from an env var and handing us nil
got a NullPointerException building the deadline rather than the default.

The test's 200 ms budget left the shell to fork twice and write both pids with no margin. On a loaded
runner the file could come up a line short, failing the descendant assertion for the wrong reason. A
second is still far below the bound the test asserts.

* Stop waiting on a command that orphaned our output pipe

sh* could sit for as long as an orphan lived, whatever its timeout said: 62 s against a 500 ms budget in
the case that prompted this. with-open closed the BufferedReader, and that close takes the same lock a
blocked readLine holds, so it waited for EOF -- which a process the command left holding our stdout never
delivers. Closing the pipe underneath ends the read at once, so the streams are now closed directly.

Closing the pipe means a cut-off read reports EOF rather than an error, which would make truncation
invisible. Two things guard against that. The readers get a floor of their own once the exit code is in,
so a command that finished a moment inside its budget is not robbed of the output it had already written.
And if the pipe is still held when that floor runs out, sh* says so and throws, because half a command's
output returned as though it were all of it is worse than a failure.

The orphan itself is still running afterwards. Reaching it needs process-group handling, which is a
larger change and no mage command leaks one today.

* Rewrite the comments and docstrings in mage.shell

The docstrings now state the caller's contract and nothing else: what sh* returns, what it throws, what
the timeout actually covers now that one budget spans the whole command. Rationale about how the body
works moved down to the lines it explains -- why the descendants are listed before anything is signalled,
why the deadline is read off a monotonic clock, why the pipes are closed rather than the readers.

* Tighten the comments once more, and drop the duration glosses

A second pass over the comment wording, and a correction: a DST change does not move the epoch
milliseconds `currentTimeMillis` reports, so it was never one of the things nanoTime protects the deadline
from. NTP corrections and manual clock changes are.

The `; 5 seconds` style glosses are gone throughout. Writing the constant as `(* 5 1000)` already says
which unit it is in, and the name carries the rest.

* Serialize mage shell output and fix test namespace loading

`println` writes the text and its newline separately, so the stdout and
stderr readers could land halves of two lines on one line of the terminal.
Lock on the destination, which also keeps two commands streaming at once
from splitting each other.

`mage -test` runs `run-all-tests`, which only sees namespaces that are
already loaded, and nothing required mage.bot.pr-env-test or
mage.bot.repl-eval-test, so eight tests never ran. The `(comment ...)`
block meant to hold these requires open only referenced an alias that
existed for its own benefit; kondo does not flag alias-less requires, so
it was protecting nothing.
2026-09-08 01:03:13 +02:00
github-automation-metabase
ada3520e32 Auto-update documentation for master (#81981)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-07 17:29:15 -04:00
Timofey Kachalov
33ebc8e671 Gate data apps remote sync logic under a token feature (#81946) 2026-09-07 20:04:42 +00:00
Andrei Simionescu
f1ea6b4ad5 BOT-1918: use the AWS default credentials chain for keyless Bedrock (#80512)
A Bedrock connection with no access key pair now signs with whatever the AWS
SDK default credentials chain resolves (environment, IRSA, EKS Pod Identity,
ECS task role, EC2 instance profile), and the SDK refreshes those on its own,
so a pod that already has a role needs no long-lived keys. Explicit keys still
override the chain, and half a pair or a lone session token is rejected.

On Metabase Cloud the chain would resolve our own identity rather than the
customer's, so a keyless connection is rejected there, and a token status the
license service could not confirm counts as hosted.
2026-09-07 18:24:18 +00:00
Will Fry
0de35e5dd3 Add Will Fry to team and slack map json files (#82028)
* Add Will Fry to team and slack map json files

* use exact casing for gh handle
2026-09-07 16:48:18 +00:00
Andrei Simionescu
0ed863ba11 BOT-1590: Test that the Conversations tab stays current on a conversation page (#81870) 2026-09-07 17:25:47 +01:00
Kamil Mielnik
da793ea976 GDGT-3189 Remove CustomVisualization['id'] and use CustomVisualization['getName'] (#82033) 2026-09-07 14:54:04 +00:00
Sébastien
7e6c62a105 Use consistent colors for the MCP errors-by-type pie chart (#81943)
* Avoid success-green slices in the MCP errors-by-type pie chart

* Use createMockStructuredDatasetQuery instead of a type cast
2026-09-07 16:51:27 +02:00
Paolo Rascunà
3ba6cb1479 BOT-2111: Stop the Gemini stream when the client cancels (#81959)
The streamGenerateContent xf walked each event's parts with a plain
`reduce`, which strips the `reduced` wrapper a cancelled consumer
returns. The stop never reached the outer reduction, so every event
that followed was still translated and pushed downstream — Google's
response was read to the end, and billed, after the client had gone.
The other adapters propagate the stop; only this one dropped it.

Use u/reduce-preserving-reduced so the early stop survives, and pin it
with a test that cancels on the first text delta: before the fix the
consumer kept receiving text from later events.

Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:23:37 +00:00
Stas Gavrylov
3c07580bcc GDGT-3188 Remove unintended elevation styles (#81958)
* GDGT-3188 Remove unintended elevation styles

* GDGT-3188 Fix remaining elevation regressions

* GDGT-3188 Update elevation guard expectations
2026-09-07 17:22:00 +03:00
Nicola Mometto
38fcd8e057 Scope tenant endpoints and pulse recipients by tenancy (#81765)
* Only serve tenant attributes and member_count to superusers

* Scope pulse recipients and the Slack directory by tenancy

* Update cross-tenant recipient test for non-superuser scoping
2026-09-07 15:15:20 +01:00
Nicola Mometto
c573e659f6 Read-check the transform in GET /api/transform/run/:run-id (#81761) 2026-09-07 14:45:17 +01:00
YB
f6ea5b6092 Fix ENOBUFS in the Create test plan CI step (#82025)
raise git ls-files maxbuffer so the test planner stops failing with enobufs
2026-09-07 21:33:49 +08:00
Sébastien
69256a104a Apply embedding theme colors to hand-picked chart series (#81014)
* fix: apply embedding theme colors to hand-picked chart series

* Clarify and dedupe chart color-picker naming

* Apply the instance's chart colors when rendering static visualizations
2026-09-07 15:00:00 +02:00
Kamil Mielnik
ff7b417ce6 GDGT-3184 Reject ':' in custom viz plugin identifiers (#81886) 2026-09-07 12:41:11 +00:00
Stas Gavrylov
0e7e969925 GDGT-2467: Curate Paper radius tokens (#81700)
* GDGT-2467: Restrict Paper padding/radius to curated token options

* Update Loki Snapshots

* GDGT-2467: Update entity picker width e2e expectation for Paper padding

* GDGT-2467: Keep Paper default padding at 0, add no-padding option to stories

* GDGT-2467: Keep all padding options in Paper stories, only curate radius

* Update Loki Snapshots

* GDGT-2467: Type PAPER_RADIUS_OPTIONS against RadiusScaleKey

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-07 15:07:41 +03:00
Nicola Mometto
1dfc2d1c90 Fix transform self cycle (#81676)
* Resolve the under-test transform's target live in cycle detection

* Split the change-target overwrite e2e test into the two cases it conflated

Retargeting onto the transform's own source table is a self-cycle (400), not a
target conflict, so exercise the conflict case with a table produced by another
transform and add a dedicated self-cycle case.

* Make the transform under test win target collisions in get-transform-cycle

* remove live-target-table-id
2026-09-07 09:49:13 +01:00
Nicola Mometto
78d3c10b00 perf: optimize fix-sql-params regex (#81893)
* Make fix-sql-params regex linear-time

The tag-body class excluded only `}`, so `\s*[^}]+\s*` had three overlapping
quantifiers and backtracked cubically on an unclosed `{ {` followed by
whitespace. Exclude `{` from the body and drop the redundant \s* so every
position matches in linear time.

* Use :standardsql dialect in test so no kondo ignore is needed
2026-09-07 09:48:49 +01:00
Kamil Mielnik
bbef4a31a2 GDGT-3185 Replace document viz settings instead of merging them (#81889) 2026-09-07 08:18:02 +00:00
Stas Gavrylov
ace7aa5dd0 Add Loki and Figma Desktop workflows to component skill (#81763) 2026-09-07 10:44:28 +03:00
Fraser
f531a39d14 Enforce the shared tiers for visualizations (#81990) 2026-09-07 16:59:45 +12:00
Romeo Van Snick
dbc843ce23 Derive each store root's state type from the slices it composes (#81481)
* Derive each store root's state type from the slices it composes

* Type the mirror's own selectors against the slice they read
2026-09-07 09:44:55 +10:00
metamben
e6216478c6 Introduce billing-project for BigQuery (#79489)
* Introduce billing-project for BigQuery

Closes QUE2-539
2026-09-05 22:29:39 +00:00
technomancy
42a8e9f77d Update create-transform-with-tags-test to be more specific about schema. (#81986)
Previously it was just picking a table at random and using that row,
but in redshift it turns out that table's row doesn't actually exist,
causing a bunch of failures. Let's look specifically at the people
test-data table instead of just using whatever.
2026-09-05 00:06:32 +00:00
Ben Grabow
6fdf223404 bgrabow/redshift-describe-database-probe (#81980)
* Probe for cause of describe database flakes

* Fix brittle test

* Delete obsolete probe test suite
2026-09-04 16:54:56 -05:00
Andrei Simionescu
70a3d8cb4a BOT-2110: sweep orphaned search index tables when a reindex starts (#81944)
Sweep orphaned search_index__* tables at the start of reindex! instead of at activation, so a rebuild
that dies partway is cleaned up by the next run instead of leaving its table behind. The sweep is best
effort and never aborts the reindex.
2026-09-04 21:52:11 +00:00
Fraser
8e3b90894e Move the drill layer from visualizations into querying (#79500) 2026-09-05 09:06:32 +12:00
Ben Grabow
a8db179087 Fix "The result set is closed" flaky reuse of connections (#80033) 2026-09-04 13:31:12 -05:00
Phoomparin Mano
ee9cd6a54e Fix getComputedStyle crash in data app sandbox (#81739)
* fix iframe parent distortion

Fix getComputedStyle calls in data app sandbox

fix frame parent distortion

* update distortion tests
2026-09-04 18:10:38 +00:00
Phoomparin Mano
96c905f643 Allow multiple useDataAppLocation consumers without crashing data apps (#81854)
Fix multiple data app location consumers
2026-09-05 01:06:40 +07:00
Riley Thompson
2a92db58c0 Don't merge db details if db engine was changed (#81968)
dont merge detials if new engine selected
2026-09-04 13:50:23 -04:00
Riley Thompson
cbfbeb3ce1 Fix implicit date conversion in postgres convertTimezone (#81912)
* add test

* Cast DATE inputs before timezone conversion on Oracle and SQL Server

`convertTimezone` on a `DATE` column failed on both drivers:

- Oracle: `FROM_TZ` only accepts a `TIMESTAMP` and refuses to implicitly
  promote a `DATE` (ORA-00932: inconsistent datatypes: expected TIMESTAMP got
  DATE). Cast date-typed inputs to `TIMESTAMP` first.
- SQL Server: `AT TIME ZONE` rejects a `date` argument (error 8116: Argument
  data type date is invalid for argument 1 of AT TIME ZONE function). Cast
  date-typed inputs to `datetime2` first.

Fixes `convert-timezone-date-column-test` for both drivers (#27186).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYP35BadwdEL9LAC9zHWQZ

* Derive expected DATE column values from the results timezone in test

`convert-timezone-date-column-test` hardcoded the raw `d` column as
midnight `+07:00`, which is only right for drivers that support
`:set-timezone`. SQL Server can't set a session timezone, so its results
timezone falls back to the database timezone (UTC in CI) and the column
formats as `Z`. Compute the expected values the same way `format-rows`
does so the assertion holds on every driver with `:convert-timezone`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYP35BadwdEL9LAC9zHWQZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 17:37:51 +00:00
technomancy
234568bf20 Deprecate merged-column-info in driver-api. (#81965)
We want to drop as much usage of legacy-mbql as we can. This function
is never used internally, but it's exported in the driver-api. We
should mark it as deprecated since everything else in this namespace
is deprecated, so we can remove it when the time comes.

Need to get this in today so it's included in the 64 branch.
2026-09-04 17:32:16 +00:00
technomancy
4be1fd71c9 Replace Bigquery/Snowflake dataset tracking table; track by name instead (#81655)
* Replace Bigquery/Snowflake dataset tracking table; track by name instead.

The current system of using a tracking table was based on the
assumption that all datasets would need to be tracked by access
time. In fact, datasets fall into one of two buckets, either they are
static and immutable, or they are temporary one-offs. Static datasets
change *very* rarely and do not necessitate automated tracking. If we
neglect to clean them up, we could go for decades before the
accumulated datasets pose a real problem. But non-static datasets are
used once and accumulate *very* rapidly, needing to be cleaned up.

All datasets defined using `defdataset` are considered static.

Rather than using a database to track which ones must be cleaned up,
the logic can be much simpler; use the date embedded in the dataset's
name. It turns out it's even simpler because the logic to track
datasets based on their name has existed this entire time since 2023
in `metabase.driver.sql.test-util.unique-prefix`; we just stopped
using it in Snowflake/Bigquery in favor of the overly-complex tracking
table which A) leaked like mad and B) mistakenly marking static
datasets as old and causing them to get deleted while they were still
in use.

Redshift already uses the `unique-prefix` namespace generating its
schemas, so it only needs a few minor changes to adapt to the changes
in the gc job and the `unique-prefix` functions.

This also simplifies the gc nightly task; rather than having two
separate settings, we only need one setting for how many hours old a
non-static dataset has to be before cleaning it up. Also adds a
`dry-run?` option for manual testing.

* Address review feedback.

* Update workflow yaml to refer to single :hours parameter
* Remove 256-row limit from bigquery/snowflake
* Fix snowflake dataset :name in report data
* Get rid of with-write-stmt helper.
* Accept tracked? arg for manually GCing tracked datasets.
2026-09-04 09:24:56 -07:00
Nicolò Pretto
374e182461 add react_version to sdk analytics (#81954) 2026-09-04 17:03:33 +02:00
Anderson
35c9ea0a29 Fix flaky 'admin > allows to undo every action' test (#81020)
* Keep the data model in sync when a change is undone

* Extract shared TableSection update handlers into a hook

* Undo a custom field order with a single request

* Guard the optimistic table patch by table id

* Reproduce the dropped refetch scenario in the middleware test

* Link the upstream RTK issue from the dropped-refetch middleware and test
2026-09-04 13:52:40 +00:00
Nicolò Pretto
2176f18597 feat(sdk, eajs): add support to show "all" collections in the browser components (#80881)
* Accept "all" as a collection id in the browser public API

Adds the public api plumbing

* refactor: own the ItemsTable container query inside ItemsTable

The CSS container was defined in CollectionContent.module.css, which meant it only worked on ItemsTable when it was rendered inside CollectionItemsTable. The virtual "all collections" root renders ItemsTable on its own, so the container moves onto ItemsTable.

* refactor: split the collection breadcrumbs into a view and its data

We'll need to customize what breadcrumbs will be rendered for the virtual root.
This commit extracts the view that we'll use directly.

* Clear the browsed collection while the next one loads

RTK Query keeps serving the previous collection from `data` while the next one
loads, and keeps serving it once the argument becomes skipToken. A caller that
reads `can_write` during the switch therefore offers to create content in the
collection the user just left.

`currentData` drops to undefined on any argument change, so it covers both
cases and the manual clearing from 397a3e82e04 goes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy

* Don't let the popped stack entry override the clicked breadcrumb

Clicking a collection breadcrumb from inside a question landed on the
collection the browser was mounted on, not on the one the user clicked.

Opening a question, a metric or a model pushes a navigation stack entry, so the
back button keeps working. That entry carried an onPop that reset the view to
the mounted collection. The breadcrumb handler sets the view to the clicked
crumb and then pops the stack, so onPop ran last and overwrote the click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy

* Render the virtual "all collections" root in the collection browser

collectionId="all" now lists everything the current user can reach, as flat
siblings, in the same order the entity picker uses: "Our analytics", the shared
tenant collections, "Our data" and the personal collection.

useAllCollectionsItems builds those rows:

- A 403 on GET /api/collection/root means the user cannot read "Our analytics"
  itself, so the hook falls back to /api/collection/root/items, which has no
  read check on the root. The models=collection argument is required: without
  it the endpoint returns no models at all.

- "Our analytics" needs a numeric id for the table, so it gets 0. Real
  collection ids start at 1, and the caller maps 0 back to "root".

- The shared tenant collections come from the collection tree, gated on the
  use-tenants setting and on the tenants plugin. shallow=true keeps the
  backend from building a nested tree the caller never reads.

- The backend excludes the tenant collection from /api/collection/root/items,
  so the hook adds it from the current user.

CollectionBrowser renders the rows with ItemsTable directly, without the
archive column: archiving resolves its permissions from a parent collection,
and the virtual root has none.

The breadcrumbs put a static "All collections" crumb in front of
CollectionBreadcrumbsView. The personal and the namespaced collections lose
their "Our analytics" crumb, because the API reports them as its children
while the virtual root shows them as siblings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy

* Key the SDK breadcrumbs by type and id

Breadcrumb ids come from separate id spaces, so a collection and a dashboard
can both be 5. On an instance where collection 5 holds dashboard 5 the trail is
[all-collections "all", collection 5, dashboard 5], and two children of the
same list share the key 5. React warns about the duplicate key in the host
application's console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy

* fix sdk package type

* e2e tests

* Unit test the virtual "all collections" root

* "all collections" -> "all-collections"

Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>

* pageSize support

* Assert the error message, not the error container

The virtual root error tests only checked that the sdk-error-container
testid rendered. That proves an error appeared, not which one, so any
other failure rendering into the same container would keep them green.
They now look for "Failed to load collections".

Verified by mutation: changing that string in CollectionBrowser turns
all four tests red, where the testid version stayed green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9MrpBnd3VEiXVvYf15Qqd

* change root id from 0 to -1 and wrap the mapping

* import already existing defer

* not.toHaveLength(0) -> toBeGreaterThan(0)

* clarify that the root is 403 in the test

* `SdkBrowseCollectionId` -> `SdkBrowserCollectionId`

* docs(sdk): list the accepted collectionId values as bullets

The CollectionBrowser `collectionId` doc described five accepted values in
one running paragraph, and adding "all" pushed it past readable. Split it
into one bullet per value, and document the entity ID string, which the
paragraph never mentioned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UTu6CuVJnRjzS1HrmrJQT3

* else if

* rename the virtual root identifiers to all mode root

* refactor root status into union

* clarify when the root could be empty

* fix eslint

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
2026-09-04 14:59:46 +02:00
Kamil Mielnik
af22e8cf22 GDGT-3183 Sanitize ClickObject and HoverObject (#81885) 2026-09-04 19:57:23 +07:00
Kamil Mielnik
d3cb526e49 GDGT-3186 Custom viz SDK type-check (#81891) 2026-09-04 19:38:54 +07:00
Alexander Polyankin
6be1b2761e Warn when startup encrypts legacy plaintext, and make migrations leave none behind (#81898)
* Warn when startup or a migration encrypts data that was stored unencrypted

The settings backfill reports that settings saved by an older version are being
converted, the startup sweep reports each column whose plaintext values it
encrypted, and the migrations that encrypt existing rows report how many they
touched, so an operator can see when and why data changed at rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Find legacy unencrypted values first and hand them to a handler before encrypting them

`legacy-unencrypted-value?` decides what counts as legacy plaintext, the startup
sweep and the encrypting migrations filter their rows with it, and
`handle-legacy-unencrypted-values!` is called with the count before anything is
encrypted, so it can turn from a warning into something else later. The
migrations keep private mirrors of both, as they do for the encrypted JSON
helpers, and read their rows eagerly: reducing a reducible query closes the
transaction's connection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Call the handler's location parameter title

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Make the encryption migrations no-ops and move the app-db module's queries to db.clj

The Encrypt* custom migrations did the same work `encrypt-plaintext-columns!` does
on every startup, so their forward bodies are gone; the reversible ones keep their
decrypting rollback for downgrades. The handler now takes only a `table.column`
location. The encryption and setting namespaces run their Toucan 2 queries through
the new `metabase.app-db.db`, like the other modules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop the decrypting rollbacks of the no-op encryption migrations

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Make a fresh install come out fully encrypted from the migrations alone

The encryption-touching data migrations that only rewrote existing rows are no-ops
now that the startup sweep covers their columns, and MigrateMetricsToV2 goes with
them. Two v58 changesets left rows a fresh install would then see as legacy: the
SQL copy of password authentication into auth_identity is retired from the
changelog and redone in Clojure with the credentials encrypted, and sample content
is created by CreateSampleContentV3 once setting.value_with_aad exists so the
example-dashboard-id row is written whole. The settings backfill skips the
encryption-check sentinel, which the encryption tooling rewrites itself. The
fresh-install test asserts that setup logs no app-db warning and that the sweep
afterwards finds nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep the sample-content migration tests in version order

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Stop inserting the plaintext encryption-check marker on new databases

The v53 changeset that inserted it is retired from the changelog: the encryption
tooling writes the sentinel itself, whole, and reads a missing row the same way, so
the settings backfill no longer needs to special-case it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Delete the plaintext encryption-check marker instead of retiring its changeset

The v53 changeset inserted the marker on every new database, encrypted ones
included, so it never said anything about the actual state. It stays in the
changelog as a checksum-agnostic no-op, since a rollback to a release that still
has it must not re-run the insert, and a new changeset deletes the marker rows: with
no row the state is unknown and judged from the content, which the encryption
tooling already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Leave the v53 encryption-check changeset untouched

Its checksum is recorded on every existing database and validCheckSum: ANY does
not lift the check, so it keeps inserting the plaintext marker on a new database
and the v58 changeset deletes the row right after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep the data migrations, retire the Encrypt* changesets, and never change a row's encryption state

The migrations that rewrite encrypted JSON columns are back with their original
bodies, but `encrypted-json-in` now writes a row back the way it was stored:
encrypted only when the stored value decrypts with the current key, so a
migration never turns plaintext into ciphertext or the reverse. The Encrypt*
migrations are removed outright, with a changeset retiring their changelog
records, since encrypting legacy plaintext is the startup sweep's job.
CreateSampleContentV2 is untouched again and a small migration fills its
setting's value_with_aad from the strictly decrypted value; the LLM providers
migration writes both columns too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Address the review: decide by decrypting, stream the sweep, and write NULL rather than an empty SET

The settings backfill now treats a value as encrypted only if it decrypts, so a
plaintext value that merely looks like ciphertext is filled in instead of being
skipped forever. update-setting-values! takes the columns to set as a map, so a
value that encrypts to nothing is written as NULL as before instead of compiling to
an empty SET. The startup sweep streams each column again, warning once before the
first row it encrypts. The unused bytes predicate is gone, the string one is
private, and the two changelog-retirement changesets are one, using the changelog
table property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep the SQL copy of password authentication; the startup sweep encrypts the credentials

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Give the internal user no password

It is inactive and can never log in, and a password would be copied into
auth_identity bare by the v58 SQL changesets, leaving a fresh install with one
row for the startup sweep to encrypt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 08:01:14 -04:00
Fraser
21b94190f6 Move FieldPanel into the table columns settings widget (#81929) 2026-09-04 21:24:50 +12:00
Nicola Mometto
72951848e5 Use tighter schema for POST /api/notification/send (#81877)
The endpoint sends an unsaved notification, so its body has no row of its
own. Decode it with the create schema instead of the full hydrated one.
2026-09-04 09:54:48 +01:00
Fraser
364ec28c56 Move template_tags and native parameters list into parameters (#79127) 2026-09-04 15:32:45 +12:00
Fraser
63e4a7ccb2 Create the segments module (#81864) 2026-09-04 14:59:02 +12:00
Anderson
3fafdbd58a Aggregate static viz grid map cells when the mapped dimensions are not unique (#80354)
* Aggregate static viz grid map cells that share a lat/long bin

* Address review
2026-09-04 01:13:19 +00:00
Riley Thompson
df8d8a2f41 Add test for presto struct fields (#81903)
add test
2026-09-03 21:33:06 +00:00
Tyler Plude
7d07cc9ac8 [GHY-4377] Data sensitivity table scanning (#81752)
* add deterministic data_sensitivity rules classifier

* fill data_sensitivity rule table to full enum breadth

* add data_sensitivity scan step and opt-in setting

* register classify-data-sensitivity as the last analyze step

* document PUBLIC as scanned-or-reviewed for data_sensitivity; add end-to-end tests for the data_sensitivity scan

* label non-Text base types and integer/band variants in the data sensitivity classifier

* add reset-data-sensitivity! to clear classifier-written labels
2026-09-03 14:33:01 -07:00
github-automation-metabase
cf03ebca8d Auto-update documentation for master (#81921)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-03 17:29:02 -04:00
Fraser
a00b3d7fd1 Move CollectionBadge into common/collections (#81856) 2026-09-04 09:05:48 +12:00
Fraser
c82c593a00 Move the no-results alert prompt from querying into notifications (#81853) 2026-09-04 09:05:19 +12:00
Riley Thompson
b7a39e92b3 Add test for cumulative sum of a literal number (#81806)
* Add test for cumulative sum of a literal number (#27807)

Adds a QP test verifying that a custom aggregation expression of
CumulativeSum(1) on the Products table, broken out by Created At by
month and ordered ascending, runs successfully instead of failing
with 'Field 1: Field does not exist'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kKRbqUbfSKNViTEzS82ss

* clean up

* undo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 23:43:36 +03:00
Aleksandr Lesnenko
8444d69eb9 Show errors from the body of http 500 responses to users (#74693)
surface http 500 error messages to users
2026-09-03 20:37:04 +00:00
Fraser
295d7eaabb Dispatch fetchTableMetadata directly instead of through loadMetadataForTable (#81852) 2026-09-04 08:35:08 +12:00
Cam Saul
cf6a95c3b8 Metadata for plain-:field expressions should propagate Field ID and Table ID (#81286)
* Metadata for plain-`:field` expressions should propagate Field ID and Table ID

* Fix 🔧

* Add tests 🔧

* Test fix 🔧

* Test fix 🔧

* Test fixes 🔧

* Test fix 🔧
2026-09-03 20:05:10 +00:00
Tyler Plude
e315710292 [GHY-4378] Add data_sensitivity col to field (#81744)
* add nullable data_sensitivity column to Field and its user-settings mirror

* Expose data_sensitivity on PUT /api/field/:id

* add tests for data_sensitivity survives sync, reactivation, and base-type changes

* add serdes round-trip tests for data_sensitivity on Field and FieldUserSettings

* Expose data_sensitivity in Lib column metadata

* tweak wording
2026-09-03 13:01:43 -07:00
Chris Vandoodewaard
2b35888297 containerize uberjar from uberjar workflow (#81238)
* merge containerize-uberjar into uberjar workflow

* big refactor

* move things around, add inputs to fetch-artifact action

* update conditional logic

* clean up comments

cleanup whitespace

* simplify conditional logic

* guard conditional matrix

* review cleanup

* add job names

* cleanup whitespace

* update get-docker-info to resolve repo/tag inputs

* trim trailing whitespace

* fix linting

* simplify fetch-artifact, use continue-on-error instead
2026-09-03 14:37:13 -04:00
Eric Normand
0dfca163f0 GHY-2937: On-demand field values can take forever to load (#81753)
* Keep on-demand FieldValues fetches alive past request cancellation

On-demand FieldValues are fetched on the request thread, so a canceled HTTP
request threw the warehouse scan away: every retry started the scan over and
nothing ever landed in the app DB, which is what makes these requests feel
like they take forever.

Run the fetch on a detached thread instead. It runs to completion and persists
what it fetched even when the caller stops waiting, and concurrent callers for
the same field (and sandboxing/constraint context) share the one in-flight run
rather than each kicking off their own scan.

Fixes GHY-2937 (#67843)

* Log detached FieldValues fetch failures

When every caller has walked away — the case detached-fetch! exists for —
nobody derefs the promise, so a thrown exception was dropped without a trace.

* Don't wipe cached FieldValues when the warehouse scan fails

`distinct-values` catches and logs warehouse errors, then returns nil — but
`create-or-update-full-field-values!` destructured that nil into empty values
and handed it to `persist-field-values!`, which reads empty as "this field has
no values" and calls `clear-field-values-for-field!`. So any failed or
timed-out scan deleted the values the field already had cached, and a field
slow enough to hit the QP timeout could never keep a cache at all.

Distinguish the two cases: nil means the scan failed, so leave whatever is
cached alone and return ::fv-fetch-failed. A scan that genuinely comes back
empty still clears, as before. The sync path already made this distinction
(see fetch-distinct-per-field's :failed-fields).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Guarantee detached FieldValues fetches always complete

A registry entry left behind with its promise undelivered is worse than a
leak: every later caller for that key parks on it forever, holding a request
thread each. Two paths could do that.

The `log/warnf` added for failed fetches sits inside the catch, so an appender
that throws skips both the registry cleanup and the delivery. And the registry
entry is claimed before the work is submitted, so a rejected submit — which is
what `future`'s pool does once the JVM starts shutting down — orphans it.

Route both through `complete-fetch!`, which still drops the entry before
delivering so a woken caller starts a fresh fetch rather than re-attaching to
a finished one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Sweep and cancel stalled detached FieldValues fetches

Nothing bounded the in-flight registry except every code path remembering to
clean up after itself, which is a property that keeps not holding — the
logging call has now stranded entries twice.

Give each entry a timer and its future, and sweep on the way into
detached-fetch!, which is the only moment the registry can grow. The sweep
cancels the work and *completes* the entry rather than just forgetting it:
callers park on the promise, so dropping one undelivered leaves them blocked
forever, each holding a request thread.

Only a fetch wedged outside the query processor can reach the max age — the
warehouse query is already capped by *query-timeout-ms* (20 minutes in prod).

Removing an entry is now conditional on it still being the registered one, so
a swept fetch that later finishes can't evict its own replacement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Filter stalled fetches in the doseq rather than up front

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Cap the in-flight FieldValues registry at 100 entries

Callers park on their fetch, so the number in flight is today limited by the
request thread pool — but that is an accident of how these endpoints are
served, not something this namespace can guarantee, and it stops holding the
moment anything frees a caller early.

Past the cap, detached-fetch! stops detaching and runs the work on the calling
thread. That degrades to what happened before detaching existed — no
single-flighting, no surviving cancellation — rather than failing the request,
so a ceiling that should never be reached can't take a filter dropdown down
with it. Capacity is checked inside the swap, so the ceiling holds exactly
under concurrent claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Refuse FieldValues fetches past the in-flight cap

Reaching the cap means something is wrong — callers park on their fetch, so
the number in flight is bounded by the request thread pool unless that stops
holding. Absorb it by running the work inline and the ceiling stops being a
signal; throw a 503 and it says what it means.

Raised to 200 now that hitting it fails the request rather than degrading it:
an instance has to have raised MB_JETTY_MAXTHREADS well past its default of 50
before this is reachable at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Pin down that joining an in-flight fetch bypasses the cap

The behavior was already right — claim-fetch!'s `contains?` short-circuits
before the count check — but it reads as incidental, and a caller that only
attaches to existing work adds no entry, so the ceiling has no reason to
refuse it. Say so, and test it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Bound how long a caller waits on a detached FieldValues fetch

sweep-stalled-fetches! only runs when a request arrives, which is exactly what
the case it guards against prevents: callers parked on a wedged fetch are
request threads, so enough of them and nothing reaches the sweep to cancel
anything. The watchdog was only reachable while the system was healthy.

Bound the waiter's deref by the same max age. The expensive thing here is the
parked request threads rather than the registry entry, and giving those back
lets requests flow again — at which point the next one sweeps the corpse. No
scheduler needed.

fetch-max-age-ms stays a backstop rather than a request-latency bound: a value
nearer a sensible HTTP timeout would return the thread far sooner, but
answering before the values are ready changes what the endpoint promises, and
that wants deciding on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Report scan failures from POST /api/field/:id/rescan_values

The endpoint discarded the result and always answered {:status :success}. That
was already misleading, and the cache-wipe fix made it worse: a failed scan now
leaves the existing FieldValues untouched, so there is no observable trace of
it at all — the caller is told the scan succeeded and sees the values it had
before.

Return a 500 when the scan failed. The button already toasts on error, so this
is the difference between "Scan triggered!" and a visible failure.

Unlike the table and database endpoints, this one runs the scan synchronously,
so it is in a position to know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Serve warm advanced FieldValues without a detached fetch

The advanced branch wrapped the whole select-or-insert in detached-fetch!, so a
cache hit — one SELECT — paid a thread handoff and a registry sweep for a plain
read. field-id->field-values-for-current-user walks advanced fields serially,
so a sandboxed user with N filters paid N of them.

Look the row up on the calling thread and detach only on a miss, which is the
only case that scans the warehouse. This is what the full-values path at
get-or-create-full-field-values! already did.

The miss path gains one extra SELECT, since select-or-insert! still does its
own read before inserting. That is noise next to a warehouse scan, and keeping
select-or-insert! there is what makes two callers racing a genuine miss safe —
detached-fetch! only single-flights within one JVM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

* Stop the FieldValues tests mutating JVM-global state

Two tests could only be trusted because of a test-runner setting. The cap test
stuffed 200 fake entries into the shared in-flight registry, so anything else
asking for field values during that window would have been refused with a 503;
three tests swapped distinct-values with `with-redefs`, which rebinds the root
for the whole process. Under the current runner nothing else is running, but
that is a property of `:multithread? :vars`, not of the tests — flip it and the
failures land in other people's namespaces.

Make the two knobs dynamic so the cap test can bind the ceiling to 0 and drive
everything through the public API instead: one real fetch held in flight, a new
key refused, and a joiner admitted. That tests more than the fake entries did.

Use the thread-local `mt/with-dynamic-fn-redefs` where the work stays on
threads that inherit bindings. Where it delegates to the original, capture it
with `mt/original-fn`: `patch-vars!` installs its proxy as the root for the
life of the JVM, so a bare symbol captured after some earlier test patched the
same var would have recursed into the proxy.

The rescan test keeps `with-redefs` — `mt/user-http-request` is served on a
Jetty worker that inherits nothing, so only a root rebind is visible there.
Commented so nobody "fixes" it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:16:43 -05:00
Alexander Polyankin
30e5526fa7 Cover enable-encryption against the read-only-mode guard reading settings (#81890) 2026-09-03 14:00:47 -04:00
Jeff Bruemmer
196032c3fe docs - banner cleanup (#81895)
banner cleanup
2026-09-03 13:51:49 -04:00
Alexander Polyankin
a865fd1ad7 Address the remaining db.clj review findings: metabot, sync, search and friends (#81865) 2026-09-03 11:53:29 -04:00
Andrei Simionescu
9c04238b16 BOT-2052: stop reading six Metabot settings from the environment (#81217)
The three system prompts, the two usage limit settings and the quota message no
longer read their environment variables, so they are set through the admin page,
the API or a config file like every other setting. Name, icon and
show-illustrations keep theirs, which matches the MB_APPLICATION_* whitelabel
settings that self-hosted deployments set once at startup.

Demoting a setting used to drop it from the config file template as well, because
the template generator shared the environment variable docs filter. That filter
now sits with the docs alone, which puts use-tenants back in the template.
transforms-setup-complete is derived and never read back, so it is marked
read-only and the template stops advertising a key that does nothing.
2026-09-03 14:46:53 +00:00
Timofey Kachalov
25060e4397 Fixed outdated data app sync skill/comment (#81790) 2026-09-03 18:29:31 +04:00
Kamil Mielnik
a2979abd20 GDGT-3182 Make checkRenderable optional in custom viz (#81884) 2026-09-03 21:10:50 +07:00
Andrei Simionescu
f1ddc2317b BOT-2003: Support GLM 5.3 (#81681)
Add GLM 5.3 to the model picker whitelist on both providers
2026-09-03 13:01:26 +00:00
Chris Vandoodewaard
cb5e5847c4 use gh cli to approve/automerge instead of 3rd party action (#81599) 2026-09-03 08:47:31 -04:00
Chris Vandoodewaard
00b57255ea remove ci release comments (#81598) 2026-09-03 08:46:34 -04:00
Andrei Simionescu
fa07f544d5 Metabot: remove extraneous data from non-500 errors with an error code (#81201)
An `:error-code` means the throw site wrote this as an API response rather than
hitting an unexpected failure, so the client gets the message and the error data
and nothing else. The Metabot token-limit 402 and the transforms trial-limit 402
both returned a full backend trace before this.

Validation errors, real 500s and the older `:error_code` spelling are untouched.
2026-09-03 12:07:03 +00:00
Kevin Kelleher
c8a8941508 Documents viz setting sidebar fixes (#81740)
* documents viz setting sidebar fixes

* use Ellipsified on viz name
2026-09-03 07:19:14 -04:00
Chris Truter
94d7dd520c Show what automated ratchet PRs shrink (#81807)
* Show what automated ratchet PRs shrink

* Tweak messaging

* Fix ratchet PR body test lint
2026-09-03 12:57:39 +02:00
Jakub Chodorowicz
e4cf63ff23 gate event apis on window instance (#81680) 2026-09-03 11:03:12 +02:00
Timofey Kachalov
c73e309b13 Adjust skill guidance telling agents not to offer a "deploy" step for data apps (#81791) 2026-09-03 11:45:58 +04:00
Timofey Kachalov
8196701636 Update data apps upsell (#81786) 2026-09-03 10:56:26 +04:00
github-automation-metabase
042ec24fe9 Tighten ratchets (#81862)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-03 04:58:07 +00:00
Alexander Polyankin
ff335470e7 Route each module's app-DB queries through a db.clj namespace (#81824) 2026-09-03 04:52:56 +00:00
Fraser
75bce3e619 Move open-url out of visualizations into urls (#81842) 2026-09-03 16:14:55 +12:00
Fraser
9ddee1f434 Register the use-icon spec's own visualizations from viz-core (#81844) 2026-09-03 16:01:14 +12:00
Fraser
0337de20c1 Read the app slice instead of current-user in the redux hooks spec (#81841) 2026-09-03 15:46:09 +12:00
Fraser
c71cf2a379 Move useHelpLink from nav into common/hooks (#81843) 2026-09-03 15:45:42 +12:00
Fraser
06462d0005 Enforce shared tiers for urls (#81845) 2026-09-03 15:45:26 +12:00
Alexander Polyankin
e6aa23212c Write re-encrypted rows with plain SQL, not Toucan DML (#81829) 2026-09-02 23:42:56 +00:00
bryan
79041e2487 mcp-v2.00 prep: extract query guards + pulse helpers; additive shared-ns changes v2 needs (#81244)
* MCP v2 prep: extract query guards + pulse helpers; additive shared-ns changes v2 needs

Behavior-preserving groundwork for the v2 MCP surface, landed while v1 is
untouched and v2 is not yet present:

- NEW metabase.agent-api.query-guards (+ its test): query safety checks
  extracted so REST and MCP run identical guard logic.
- metabase.pulse: recipient-filtering and sensitive-metadata-stripping
  logic moved from private fns in api/pulse.clj into models/pulse.clj and
  exported via pulse.core / pulse.api, so REST and MCP share it. Pure move;
  existing REST pulse tests are the check (+ new direct tests).
- metabase.channel.urls: adds metric-path, transform-path, collection-path
  helpers (additive).
- metabase.metabot.scope: adds agent:{content,query,sql,delivery}:* scopes
  and collapses agent:viz:mcp-ui:{query,drill-through} into
  agent:viz:mcp-ui:* — a proven superset, backward-compatible with v1's
  gating (REPL-verified: old concrete scopes still match). Also regates
  agent:metric:* from SQL-generation to NLQ permission (scope_test).
- metabase.mcp.settings: adds mcp-v2-disabled-tools and
  mcp-query-handle-ttl-hours (additive; v1-safe; tests included).
- metabase.models.serialization.resolve{,.mp}: adds *numeric-ids-allowed?*
  dynvar; resolver miss messages no longer name a specific discovery tool
  (the calling surface owns the recovery sentence) — mp_test updated.
- metabase.server.streaming-response: optional :executor option threaded
  through do-f-async; default path (shared pool) unchanged. Needed by
  long-lived SSE keepalive streams so they can't exhaust the fixed pool.

Review question for this PR: does REST/v1 behavior stay identical?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Review fixes (2026-08-27 deep review of #81244):
- native-marker? requires a truthy :native value (an explicit-null key from a
  JSON round-trip is not a marker); docstrings state the guard/agent-api
  sequencing explicitly (live v1 path untouched; call sites migrate at retirement).
- check-mcp-ui-native-query! accepts agent:sql:execute (v1's concrete scope)
  alongside agent:sql:run, and documents that scope claims arrive with the v2
  session rework — until then native-over-UI-credential fails closed.
- mcp-query-handle-ttl-hours getter no longer rethrows on an unparseable env
  var (falls back to the default instead of killing the GC task).
- *numeric-ids-allowed?* docstring rewritten in future tense naming its binder
  (the v2 query pipeline) and reader (the dialect rework PR).
- viz-scope comment corrected (v1 resources still register those strings until
  retirement; wildcard keeps them satisfied); mp.clj database-id tru args
  str-wrapped against MessageFormat locale grouping; streaming-response
  :executor docstring notes the Future.cancel(true) interruption requirement.

Follow-up (review round 2): the guards are now genuinely shared — agent-api's
private copies are deleted and its execution paths call query-guards directly.
Deliberate tightening: raw-scan native detection replaces normalize-then-inspect
(fail-closed on unparseable payloads). Full agent-api api-test green.

Re-review round: agent:sql:execute compat branch now tested; TTL getter's
catch path tested ("forever"); reject-native-query! no longer echoes the
payload in ex-data (matches the old live rejection shape); viz-scope comment
typo fixed; pure predicate tests ^:parallel; check-token-query-permissions!
docstring states the stage-0-fast-path/QP-backstop contract.

--- Close-out review response (slice 00's first adversarial round) ---

CRITICAL (native-query false-positive on legacy MBQL with a field/expression/tag named `native`)
FIXED. native-query? scanned the whole payload via tree-seq, so a legit query whose :expressions or
:template-tags map had a key named "native" was rejected (400) though it ran fine on master. It now
scans only the structural query-nesting edges (:stages/:joins, :query/:source-query), never
descending into caller-named :expressions/:template-tags; a malformed value off a structural edge is
still deep-scanned to stay fail-closed. Proven: a 17-case REPL matrix — every crafted false-positive
now false, every genuine native (legacy top/nested, MBQL5 stage, joins, string-keyed, malformed)
still true.

SECURITY (v1 MCP bearer authenticates a deactivated user) FIXED. resolve-access-token — the single
resolver shared by the v1 transport and the core session bearer bridge — now gates on
(t2/exists? :model/User :id user-id :is_active true). v1's transport dispatched straight on :user-id
with no active check; v2 was already refuse-only. Proven fails-when-broken: reverting the guard lets
a deactivated user's token resolve; restored, it is refused. v1 mcp.api-test (598 assertions) green.

IMPORTANT fixed: agent:delivery:write now granted by the metabot-other-tools bucket (was dead
config → alert_write would 403 everyone); metabot-nlq grants agent:content:read not :* (was
over-granting write to NLQ-only users); update-pulse-with-perm-checks! validates :cards against
CoercibleToCardRef (a bad string id was a 500, now a 400); check-mcp-ui-native-query!'s docstrings
now state it is dormant until the v2 session-rework slice wires it (was claiming to be live).

MINOR fixed: thrown-status test helper distinguishes no-throw from throw-without-status; kill-switch
check moved after the scope check (a scope-denied caller no longer learns the kill-switch state);
the query-handle-ttl default is one const, not two literals; mp_test gains positive assertions.

Re-verified (fresh JVM, run separately): query-guards + scope + settings + mp 328 assertions;
oauth + v1 mcp.api-test green. The one red in pulse-test is create-csv-xls-test = GDGT-3161, a
pre-existing EE-app-db bug (empty {} dataset_query -> download-perms 500) in the CREATE path (not
this fix's update path), reproducing on plain master — not introduced here. kondo 0/0; cljfmt clean;
modules-config regenerated (oauth-server :model-imports gains :model/User).

--- master parity sync (user-approved Option A) ---
Carries master 8c3ea6f40b (#80554 Remove Explorations dimension anchored blocks) + master search/appdb/index.clj (isolate-write! savepoint isolation). Stack forked before both; without them the dropped exploration_block.type migration is absent -> Liquibase changelog-integrity failure on real app-DBs (App-DB Part2 CI). Synced at base to restore parity. 41 files, all slice-untouched.

* Fix save-access-token! call arity after master merge

Master gave the test helper a 5th arg (client-id via
oauth-server.tu/with-oauth-client); the new
resolve-access-token-deactivated-user-test still called the 4-arg form.
Wrap it in with-oauth-client and pass client-id, matching every other
call site. Verified: full session-oauth-test namespace green locally
(10 tests, 27 assertions, 0 failures).

* Fix native-guard snake_case gap and make the tenant recipient filter work end-to-end

- native-map-edges gains :source_query/"source_query": legacy MBQL
  normalization canonicalizes the snake_case spelling too, so a payload
  like {"query" {"source_query" {"native" ...}}} reached the query
  processor as a native stage while native-query? said false — an
  MBQL-only-scoped caller could run raw SQL through /v1/execute,
  bypassing the scope split and the execute-sql kill switch. Regression
  tests cover both the string and keyword snake forms.

- The cross-tenant recipient filter compared :tenant_id on recipient
  maps that the :recipients hydration never populates, so a tenant
  caller lost every user recipient and a tenantless caller was filtered
  by nil = nil. Tenant membership is now looked up from the app DB
  inside the filter, and the filter only engages for a caller who
  belongs to a tenant — a tenantless user sees recipients unfiltered,
  as before tenants existed. The test now builds real users in real
  tenants (production-shaped recipient maps, ids only) instead of
  fabricating :tenant_id keys, and pins the tenantless-caller
  passthrough.

* Match native-SQL markers case- and separator-insensitively

`native-query?` matched edge names and markers case-exactly, but the QP
normalizer canonicalizes keys case-insensitively and treats `_` and `-`
alike. `{:query {:SOURCE_QUERY {:native "select 1"}}}` therefore scanned
as not-native and then executed as a native stage — REPL-confirmed, along
with `:Source_Query` and an uppercased `:NATIVE` marker.

That is a live bypass rather than a latent one: since
`+refuse-unscoped-native-sql` went live on /api/dataset, this scan is the
only gate between an unrestricted-stamped MCP UI credential and raw SQL.
The same scan backs `reject-native-query!` on the agent-api execution
paths, so those inherit the fix.

All key and value matching now goes through one `token` fn — namespace
kept, lowercased, `_` folded to `-`. Nodes are walked as `[token value]`
pairs rather than by fixed lookup, so a node carrying two spellings of
one edge has both scanned instead of one silently shadowing the other.

* Deep-scan a non-map stage element instead of throwing

`native-query?`'s `scan-map` iterated the node's entries without checking it was a map, so a
junk payload like `{:stages [1]}` threw "Don't know how to create ISeq from Integer" and
`/v2/query` answered 500 where shape validation should have answered 400
(`combined-query-rejects-malformed-payload-test`). A non-map where a stage belongs is now
deep-scanned like every other malformed sub-tree: never throws, and a marker after it still
trips the guard.

* Address round-3 review findings on the extraction slice

- The bearer resolver's is_active gate is defense in depth, not the primary control: deactivating
  through the model already revokes the user's tokens. The docstring now says so, and the test
  deactivates with a raw core_user UPDATE (the path the gate exists for) and asserts the token was
  not revoked, so the gate is what the test exercises.
- Pin that a tenant-scoped caller sees only same-tenant pulse recipients (tenantless users hidden).
- scope.clj comments described a wildcard collapse that is not in this diff; say what the slice
  does: adds scopes, removes none.
- Correct the guard test docstring: the /api/dataset gate is wired by the next slice.
- Pin the guard's deep-scan fallbacks and the streaming response's :executor option with tests.

* Widen the recipient merge-back to cover the tenant filter

Addresses review feedback on the tenant recipient filter: a filtered read
that is submitted back as a write silently deleted the recipients the
caller was never shown.

The `:channels` submitted to an update are authoritative --
`update-notification-channels!` deletes any recipient not present -- so
every recipient hidden on the read path has to be restored on the write
path. Master already did this for one read filter (sandboxed and
impersonated users, via `maybe-add-recipients`), but the tenant filter
added in this slice had no corresponding write-path compensation. A
tenant-scoped caller doing GET then PUT of the returned body therefore
dropped every other-tenant and tenantless recipient row.

Rather than add a second parallel mechanism, the tenant visibility rule
is now a single predicate (`recipient-visible-to-tenant-caller?`) used by
both sides: the read path filters with it, and the write path merges back
exactly what it excluded via `hidden-cross-tenant-recipients`.

Two supporting fixes:

- `maybe-add-recipients` now normalizes `:channel_type` the way
  `email-channel` does. The old `(= "email" ...)` string comparison only
  matched the REST shape, so a direct call with a hydrated pulse (keyword
  `:email`) skipped the merge entirely -- which also made the pre-existing
  sandbox protection REST-only.
- Merged recipients are de-duplicated by `(some-fn :id :email)`, the same
  key `update-pulse-with-perm-checks!` uses to diff recipients, so a
  caller matching both filters cannot get a recipient appended twice.

Verified in a REPL: the new endpoint-level round-trip test goes red
without the merge-back (two recipient rows destroyed) and green with it.
It asserts on `pulse_channel_recipient` rows rather than the API response,
since the response is filtered either way and cannot distinguish "hidden"
from "deleted". Pulse, pulse-channel and enterprise sandbox pulse suites
show no new failures (`create-csv-xls-test` fails identically on the
unmodified branch).

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
2026-09-02 23:18:07 +00:00
technomancy
9d216767d0 Remove be-tests-databricks-multi-catalog-ee job. (#81127)
In https://github.com/metabase/metabase/pull/80957 we trimmed this job
down so it no longer doubles the tests run. But it's still pointless
to have it as a separate job; it should be tested inline with the main
suite.

We still run some individual tests twice using the
`with-and-without-multi-level` macro when we need to cover both the
multi-catalog behavior and the non-multi-catalog behavior, but this is
only a handful of tests now instead of the entire suite or the entire
namespace.

Removal of `when-not (tx/db-test-env-var :databricks :multi-level-schema false)`
causes a lot of indentation changes here, so it's best to review with
whitespace diffs turned off.
2026-09-02 22:35:39 +00:00
Alexander Polyankin
a9a062731b Store setting values encrypted under the setting's name (#81751) 2026-09-02 17:26:10 -04:00
github-automation-metabase
a7090f30ba Auto-update documentation for master (#81820)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-02 21:23:08 +00:00
Andrei Chernykh
8a7375b368 Split the SSO auth settings pages into card sections (#81750)
* Split the SSO auth settings pages into card sections

* Deflake modal escape close in data-model e2e specs

* Move docs changes to a separate PR

* Finish token-scale translation in the merged css modules

* Deflake dark mode toggle assertions in palette spec
2026-09-02 13:44:47 -07:00
Nathan Voxland
a55b6b6b8a Better handling of unparsable user settings (#78737) 2026-09-02 15:15:33 -05:00
Nathan Voxland
58819b5ea2 Pivot table download should respect currency formatting (#75969) 2026-09-02 14:23:06 -05:00
Tyler Plude
0c06e94821 [BOT-1838] Release JDBC savepoints when nested transactions OK (#77562)
* release JDBC savepoints when nested transactions succeed

* tighten inline comment

* pr feedback

* Release savepoints only when no later scope is still running
2026-09-02 12:09:44 -07:00
Andrei Simionescu
e34e6190c6 BOT-1927: gate audit-view chart queries behind a Run query button (#81215)
* BOT-1927: gate audit-view chart queries behind a Run query button

* Track the Run query click with useDisclosure instead of useState
2026-09-02 18:00:34 +00:00
github-automation-metabase
eeba22c5fe Tighten ratchets (#81805)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-02 17:57:02 +00:00
Anna Tomka
a06e24c8bd GDGT-3104: Fix unreadable active tab count badge in dark theme (#81800)
Fix unreadable active tab count badge in dark theme (GDGT-3104)
2026-09-02 19:46:00 +02:00
Riley Thompson
af1c635d4e Fix sqlglot parse failure for ?::type placeholder casts (#81381)
* Fix sqlglot parse failure for ?::type placeholder casts (#81373)

With connection impersonation enabled, native queries are parsed by
sqlglot after template-variable substitution, when {{var}} has already
become a JDBC `?` placeholder. sqlglot's base tokenizer treats `?::` as
a single token (DuckDB's try-cast operator) in every dialect, and only
DuckDB's parser can consume it, so `{{var}}::date` -- a very common
pattern on Postgres/Redshift/Snowflake -- failed with "Unable to parse
native query".

Fix: before parsing in is_single_stmt_of_type, rewrite `?::` to `? ::`
(which sqlglot parses fine, emitting CAST(? AS type)). Occurrences are
located via the tokenizer's own QDCOLON tokens, so `?::` inside string
literals, dollar-quoted strings, and comments is left untouched, and
placeholder order is preserved in the re-emitted SQL. Multi-statement
rejection and the rest of the impersonation validation are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkBejATyUcaq2R74xFd5M1

* comments and tests

* Assert all placeholders survive; fix QDCOLON dialect guard (databricks, not duckdb)

Review feedback: the placeholder-cast test now asserts that every ? in the
input survives into the re-emitted SQL, not just that one does.

While adding duckdb coverage, it turned out the dialect guard in
_split_placeholder_casts named the wrong dialect: in sqlglot 28.6.0 the
?:: (QDCOLON) token is emitted by the *base* tokenizer but only
Databricks' parser consumes it (the expr?::type try-cast column
operator); duckdb's parser does not, so duckdb was still failing on
placeholder casts. The guard now skips databricks instead, duckdb gets
the same ?:: -> ? :: rewrite as everyone else, and tests cover both:
duckdb placeholder casts parse with placeholders preserved, and
databricks' native try-cast is left intact (re-emitted as TRY_CAST).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* clean up

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-02 13:42:38 -04:00
Chris Truter
30f9f25ba9 Refine kondo ratchet commands and prompts (#81776)
* Make ratchet shrinking explicit, and check it in one place

The ratchet test fixture ran `fix!` on every non-CI run, so `project-tests
ratchets` rewrote .clj-kondo/ratchets.edn in your working tree before
asserting against it. A check that edits what it checks leaves a diff nobody
asked for, in the file every PR conflicts over, and its only reachable
failure was a bug in the fixer that had just run.

Removing the auto-fix forces the dev/CI split to go too: a strict local test
that no longer tightens would fail on every branch that removes an ignore.
So over-budget is now the rule everywhere, and shrinking is only
`./bin/mage kondo-ratchets-shrink`, which the shrink workflow runs on master.

That left two implementations of nearly the same check. The babashka
`check-report` gains the justification finding — the one assertion the JVM
suite had that it lacked — so `./bin/mage kondo-ratchets` covers over-budget,
config over-budget, unjustified ignores, normalization and unknown linters on
its own. CI runs it instead of booting a JVM, and the five now-duplicated
in-repo assertions leave the test namespace, which drops from 6.2s to 0.8s
because it no longer walks the source tree.

Task names follow: check-kondo-ratchets (which nothing called) becomes
kondo-ratchets, fix-kondo-ratchets becomes kondo-ratchets-shrink, and the
merge task becomes private -kondo-ratchets-merge, since only the git merge
driver invokes it. bin/merge-kondo-ratchets keeps its name; installed merge
drivers point at it.

* Polish kondo ratchet guidance

* Prime the ratchet shrink workflow

Intentionally raise the :case-symbol-test budget from 1 to 2 without adding a suppression. The read-only check allows the unused budget; after this PR merges, the shrink workflow should restore it to 1 through a Tighten ratchets automation PR.

* Make the ratchet suite check the project

Keep the ratchet tooling unit tests in the backend suite, but make project-tests ratchets run the read-only repository check. Include it in the bare project-tests command so local project-level checks still validate the checked-in ratchet policy.

* List project-test suites once

* Address review findings on the ratchet check

check-report threw NPE on a ratchets map without :comment-exempt, while
tolerating a missing :ignore-counts or :config-counts. Default it, so the
three policy keys behave alike.

The run-project-ratchet-checks description and the step comment both still
described the old wiring. The check now runs alongside the backend checks,
which no longer include it, and "no JVM" is not quite true: resolving
clj-kondo on a cold cache shells out to the JDK prepare-backend installs.

* Warn about stale comment exemptions

The only check that a :comment-exempt entry is still needed was a local-only
test, which went vacuous when the dev and CI semantics were unified, so
nothing named a stale exemption any more. The check now reports one the way
it reports an :unlimited policy nobody uses: informational, never a failure,
since only the shrink workflow removes them.

* Say why the merge driver finds mage through its own path

The script runs against whatever repository holds the conflict, which is not
always the one it lives in -- the tests merge stages in a throwaway repo. A
relative bin/mage looks plausible and fails there, so record the reason.

* Stop wrapping two comments that did not need it

One fits on a single line once it says less. The other breaks at its sentence
boundary rather than mid-clause.

* Break the ratchets header on its sentences

Four sentences started mid-line and wrapped mid-clause, one of them trailing
"in the PR." onto a line of its own. Same words, one sentence per line.

* Keep comment exemptions until someone removes them

fix! dropped a :comment-exempt entry as soon as every ignore of its linter
carried a comment, or the last uncommented one was deleted -- so an exemption
could disappear as a side effect of unrelated work, and getting it back is a
change to defend in review.

An exemption records a decision about a linter, not a count, which is the
reason :unlimited policies are already kept at zero and only warned about.
Treat the two alike: the check and the shrinker name a stale exemption, and a
person deletes it.
2026-09-02 17:37:16 +00:00
Jakub Chodorowicz
89da67a9f3 Gdgt 3146 remap cypress maildev ports so they don't conflict with app maildev 2026-09-02 19:36:37 +02:00
Ben Grabow
ae10677d61 Use a single future for a batched feature check in tests (#81743)
Use a single future for a batched feature check when per-feature memoization is disabled
2026-09-02 11:31:48 -05:00
Ben Grabow
3bcaab2e3b Avoid has_any_column_privilege run on entire cluster (#81734)
* Avoid has_any_column_privilege run on entire cluster

* Ensure that privilege filter only lets through `true?` values
2026-09-02 11:29:54 -05:00
Nathan Voxland
2921ecdcf9 Fix image tile url tracking (#81371) 2026-09-02 11:23:22 -05:00
Kevin Kelleher
8819a72555 Prompt to reload after changing settings that update CSP (#80906)
* new domains settings page with reload on save

* review feedback

* better null safety
2026-09-02 15:05:07 +00:00
metamben
22e8ddba1f Return mongodb's collection in QP /native API response (#80918)
Fixes QUE2-822
2026-09-02 17:23:43 +03:00
metamben
8dbfef0fe6 Use the underlying nested field name (#81640)
Closes QUE2-834
Closes #81546
2026-09-02 17:22:55 +03:00
metamben
de7cedfeb6 Only set SQL Server instanceName when specified (#81595)
Closes QUE2-829
Closes 81270
2026-09-02 17:22:35 +03:00
Stas Gavrylov
5885d30ad0 GDGT-2486 Add elevation, radius, and spacing scale variables (#80481)
* GDGT-2486 Add elevation, radius, and spacing scale variables

* GDGT-2486 Remap scale tokens in files merged from master

* Address review

Review fixes for the GDGT-2486 scale migration:

- Revert double-shifted tokens from the merge-remap pass: it re-applied
  the old-to-new mapping to 22 files already remapped by the first pass,
  shifting spacing values one extra step (e.g. xl 24px -> xxl 32px).
- Fix codemod escapes where old key names kept their name but changed px:
  Modal default padding (lg->xl, 24px), MultiSelect radius (sm->xs, 6px),
  MetricSearchInput radius-sm, ThemeEditor PreviewPanel radius-md,
  ColumnInput theme.spacing.xs->xxs, ChartTypeSettings Space h props,
  TableRowForCacheableItem icon placeholder w (md->lg, 16px),
  ResizeHandle/ResizableBoxHandle grip h (xs->xxs, 4px).
- Pin Group/Stack default gap to "lg" (16px): Mantine's default is
  "md", which is 12px in the new scale (was 16px in the old scale).
- Allowlist the runtime-defined --mantine-spacing-xxs/-xxl and
  --mantine-radius-xxs CSS variables in find-never-defined-css-variables
  (fixes fe-lint CI).
- Add theme.unit.spec.ts covering the spacing/radius/shadow scales,
  dark-mode shadow set, whitelabel independence, and a guard asserting
  every scale-based component default resolves to a defined key.
- Derive Card/Paper story argTypes options from the real scale.
- Augment MantineThemeSizesOverride so the custom scale tokens appear in
  editor autocomplete for p/m/gap/radius/shadow props.
- Update the emotion-migrate and metabase-ui-component-from-figma skill
  token tables to the new scale (spacing/radius values, shadow tokens,
  snap tables).

* Update Loki Snapshots

* Address review: fix h/w style props missed by the scale codemod

The codemod did not remap height/width style props, so every bare h=/w=
scale-key value silently changed px with the new scale. Remap each to the
px-preserving key (xs 4px->xxs, md 16px->lg, lg 24px->xl, xl 32px->xxl):

- PaneHeader breadcrumb row h xl->xxl (32px, restores data studio
  pane header height)
- BrowseDatabases logo boxes h/w xl->xxl (32px)
- DatabaseConnectionStringField rows h lg->xl (24px)
- DatabaseEngineList logo Center h/w lg->xl (24px)
- Metabot chat chevron flexes h/w md->lg (16px)
- MetricsFilterPill h lg->xl (24px)
- DatasetNotebook divider h xs->xxs (4px)
- DataReference NodeList icon w md->lg (16px)
- transforms-python ResizableBoxHandle grip h xs->xxs (4px)

Audited: no remaining bare h/w/maw/mah/miw/mih old-key props whose px
changed (verified line-identical against master for every occurrence).

* Address review: fix bdrs and responsive-object size tokens missed by the codemod

Two more escape classes from the scale codemod, found by a full sweep of
every touched-token form (string style props, responsive objects, CSS
vars, theme accessors) with each line compared against master:

- bdrs style prop (border-radius): remapped px-preservingly at 69 sites
  in 58 files (xs 4px->xxs, sm 6px->xs, md 8px->sm). The codemod remapped
  radius= but not its style-prop alias bdrs.
- Responsive object values (e.g. p={{ base: "md", sm: "xl" }}): remapped
  the token VALUES at 9 sites in 6 files (base/sm/md breakpoint keys are
  untouched; only their spacing values shifted: md 16->lg, xl 32->xxl,
  xs 4->xxs).

Sweep result after fixes: zero remaining line-identical old-key token
references to spacing xs/md/lg/xl, radius xs/sm/md, or shadow xs/md in
any form. Intentional survivors unchanged: radius/shadow/bdrs "lg"
(disclosed broken-key-now-16px class), spacing/radius sm where the
px value did not change.

Verified: tsc, eslint (changed files), metabase/ui jest suite.

* Address review: fix remaining token escape forms (object props, braces props, style objects)

Final codemod escape classes from the exhaustive token sweep, all
remapped px-preservingly:

- Object-literal / default-parameter prop values (15 sites / 8 files):
  ItemList picker-item default containerProps pb xs->xxs (4px, the
  reported gap), FormFooter pt lg->xl, SlackSetup pt lg->xl,
  EmbeddingSecretKeyWidget mb xs->xxs, CreateDashboardForm labelProps
  mb xs->xxs x2, databases styles mb lg->xl, NotebookDataPicker
  menuDropdownProps mt xl->xxl, Banner story py md->lg
- Braces-wrapped string props: CloudSMTPConnectionCard p lg->xl (24px)
  and gap md->lg (16px), SettingsLdapForm mt xs->xxs,
  DashboardLeaveConfirmationModal mb lg->xl
- style-object CSS properties: DatasetNotebook borderRadius xs->xxs
- Ternary token values inside responsive objects: SummarizeStep gap
  md->lg (16px)

Sweep now returns zero real misses across every checked form: string
style props (incl. h/w/bdrs), braces props, responsive objects with
ternaries, object-literal/default-param props, style-object CSS props,
CSS var() references, theme.* accessors. Remaining grep hits are
untouched scales (lh, fz, size, breakpoints) or px-identical sm values.

* Address review: preserve Mantine spacing defaults exposed by Loki

* Update Loki Snapshots

* Address review: fix remaining scale regressions and visual guards

* Address review: make Loki update capture new references

* Update Loki Snapshots

* Address review: keep only targeted Loki references

* Address review: assign ticket component elevations

* Update Loki Snapshots

* Address review: tighten MonthPicker header spacing

* Address review: refine elevation visual stories

* Update Loki Snapshots

* Address review: keep renamed Menu Loki story selected

* Update Loki Snapshots

* Address review: preserve inherited Mantine defaults

* Address review: allow stock Mantine shadow tokens

* Address review: replace token audit test with ESLint rule

* Address review: drop unrelated whitespace-only dev/src changes

* Address review: use rem() in radius scale and rem docs

* Update Loki Snapshots

* GDGT-2486: Address PR comments

* GDGT-2486: Adjust negative tokens

* GDGT-2486: Address review findings

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-02 17:21:33 +03:00
Nicola Mometto
0b4bbf0427 Use a generic error message for the parameter-permission 403 (#81762) 2026-09-02 15:20:45 +01:00
lbrdnk
74e847104b Clean up the ai benchmark workflow (#80532)
* Clean up the ai-benchmark.yml

* Address review
2026-09-02 15:24:31 +02:00
Sébastien
bf5778bc50 Fix flaky embed parameters e2e by pre-accepting embedding terms (#81322)
Pre-accept embedding terms in embed parameters e2e setup
2026-09-02 14:53:32 +02:00
Kevin Kelleher
386fe3b12a Fix small Explorations UI issues (#81730)
fix comment badge line height, background color overflowing rounded borders
2026-09-02 08:37:41 -04:00
Andrei Simionescu
dc3f2134cb Move the Metabot model capability checks into the provider catalog (#81679)
* Move the Metabot model capability checks into the provider catalog
* Move the fast-mode proxy check into the Claude adapter
2026-09-02 12:10:51 +01:00
Jakub Chodorowicz
30be3958a2 Fix double-coercion in DOM distortions (#81672) 2026-09-02 11:18:06 +02:00
Andrei Simionescu
58ad1ba564 BOT-1033: Add box plot support to the metabot chart tools (#81198)
* BOT-1033: Add box plot support to the metabot chart tools

* BOT-1033: Narrow the chart-types docstring to the enums that derive from it
2026-09-02 10:03:13 +01:00
Sébastien
0da75ad4a3 Deflake the SQL default-parameters embed wizard test (#81759)
Wait for the preview query before asserting embed parameter results
2026-09-02 10:56:38 +02:00
Bartosz Rybacki
b3365881d0 Add brybacki to team and slack map (#81702) 2026-09-02 10:26:14 +02:00
Anna Tomka
663a04c231 chore: add annatomka as team member (#81713) 2026-09-02 10:03:21 +02:00
Kamil Mielnik
fc3927c030 Add core-frontend-gadget as codeowners of custom viz (#81333) 2026-09-02 14:27:49 +07:00
Sébastien
7a20107f87 Prefer the X-Metabase-Session header over the session cookie (#81165)
* Prefer the X-Metabase-Session header over the session cookie

* Don't fall back to the session cookie when the header is stale

* comment
2026-09-02 06:41:28 +02:00
Romeo Van Snick
577f242c41 Move the entities store into the metadata module and hide normalizr (#81290)
* Move the entities store into the metadata module and hide normalizr

* Record the moved schema file's import-time effect

* Describe what the metadata reducers do, not what they replaced

* Move the mirror's fixtures and slice-name helpers into the module (#81297)

* Move the mirror's fixtures and slice-name helpers into the module

* Build the QuestionSources fixture through the metadata store barrel

* Document why the metadata store is not a deletable mirror slice

* Trim the barrel and write-door comments
2026-09-02 10:46:02 +10:00
Nathan Voxland
de202b144a Create personal colection on user create (#78714) 2026-09-01 17:35:56 -05:00
dpsutton
9bf3b136af only derive site-url from authenticated superuser requests (#81130)
* only derive site-url from authenticated superuser requests

`maybe-set-site-url*` (global Ring middleware) derived `site-url` from request
headers whenever `(or (not (setup/has-user-setup)) superuser?)`. The left arm
needed no authenticated caller, so an unauthenticated request reaching the
instance before its first user was created could pin `site-url` from a forged
Host/Origin/X-Forwarded-Host header. Because the write is first-writer-wins and
setup never overwrites it, that attacker-chosen value survived setup and
poisoned every later auth/email link (notably password-reset URLs) for the life
of the instance.

Gate derivation on `superuser?` alone. `:is-superuser?` is already on the request
by the time this middleware runs (wrap-current-user-info is inner/earlier in the
onion), so an admin's own request still auto-derives `site-url` -- which happens
on the operator's next request after an interactive setup. An unauthenticated
pre-setup request now writes nothing; `site-url` simply stays unset (nil is
handled everywhere it's consumed, and a reset link built on nil is relative,
never attacker-controlled).

Deployments that provision headlessly, run multi-tenant, or never sign in as an
admin will otherwise sit at a nil site-url, so note in the setting's docs that
they should set MB_SITE_URL explicitly.

* Read Quartz job-data blobs through a class allow-list

Quartz persists job data, calendars, and BLOB triggers as serialized objects.
Metabase's job data is only ever EDN-shaped data (keywords, strings, numbers,
maps/sets/vectors) wrapped in Quartz's own JobDataMap/trigger classes, so when we
read it back we constrain deserialization to exactly those classes and decline
anything else.

Install a per-DB DriverDelegate (StdJDBCDelegate for H2/MySQL, PostgreSQLDelegate
for Postgres) that overrides getObjectFromBlob to read through a class allow-list
built from the plain-data classes job data consists of. This is scoped to
Quartz's own blob reads, so nothing else in the JVM is affected.

- metabase.util.deserialization-allowlist: a reusable, composable ObjectInputFilter
  allow-list. clojure-data-prefixes covers the EDN value space; callers add their
  own wrapper prefixes and read via read-object / read-object-bytes.
- metabase.task.secure-delegate: the allow-set (EDN + org.quartz.*), the blob-read
  bodies, and install!, wired into task.bootstrap. The gen-class delegates are
  AOT-compiled in the jar; from source they're compiled on demand so the class is
  loadable by name through the classloader Quartz uses. That runtime compile is
  fenced to source only (config/jar?): from a jar a missing class is a build error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

* Don't deserialize Java objects from H2 query results

Metabase reads scalar and collection values out of query results, never
serialized Java objects. Register a JavaObjectSerializer on the H2 driver so H2's
JAVA_OBJECT (de)serialization is declined rather than reconstructing arbitrary
classes from stored bytes. With that in place the ad hoc JAVA_OBJECT check in
read-column-thunk is redundant, so it's removed; read-column-thunk keeps its CLOB
handling. Ordinary values are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

* Strip internal keys from binning maps

Add :decode/api and :encode/serialize remove-internal-keys hooks to the
::binning schema so clients can't smuggle internal query-processor keys in
through a :binning map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Strip internal keys from more client-submitted query maps

Extend the :decode/api / :encode/serialize remove-internal-keys hooks to the
::parameter, ::template-tag, ::constraints, ::middleware-options, and ::page
schemas so clients can't smuggle internal query-processor keys in through them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Reformat strip-internal-keys-test doseq bindings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test improvements and fix reflection warnings

* Validate HoneySQL idents at the app-DB compile step

Honey SQL renders an ident as bare, unquoted SQL in several positions --
a form head it doesn't recognize, an ORDER BY direction, a DDL modifier,
the function name of a `:%fn.arg` var -- so an ident reaching any of them
is raw SQL text. Hold every ident to a plain-identifier shape, with an
explicit allow-list for the keywords Honey SQL renders as fixed SQL of its
own (operators, DDL modifiers, column types, ORDER BY directions).

Also close two gaps found along the way: symbols walked past the `:raw` and
`:inline` gates, since Honey SQL resolves symbol heads and clause keys the
same as keywords; and `safe-value?` trusted any value shape it didn't
recognize, where it now enumerates the types bound as `?` parameters.

Query idents that were kebab-case are renamed in the SQL only -- the
Clojure keys they feed, including the `:metabase-user-id` request key, are
mapped back at the read site and unchanged for their consumers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Allow the Postgres operators our drivers register globally

`metabase.driver.postgres` registers the PG JSON, array and regex operators
with `honey.sql/register-op!`, which makes them global -- and the app DB can
be Postgres, so app-DB queries reach for them too: the semantic-search
migration compiles a `[:->> ...]`. Add them, and a test that holds the
allow-list to every non-namespaced operator Honey SQL knows, so an upgrade
or a new registration can't silently leave one out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Snake_case the remaining app-DB query idents

Four more sites the sweeps turned up, all renamed in the SQL only:

- `request/session.clj` selects the current user under the same kebab
  aliases the session middleware did, so it gets the same rename-at-the-read
  -site treatment.
- Search scorer names double as SQL aliases, and three of them are
  kebab-case. They also key the weights in `search.config` and name the
  scores the API returns, so the conversion happens at the one seam that
  writes and reads the alias, leaving the scorer name alone. The alias gains
  a `_score` suffix because `:view-count` and `:data-layer` are named after
  the very columns they read.
- The model alias registry in `api/common.clj` feeds
  `column-with-model-alias`, which assembles `alias.column` idents, so its
  six kebab aliases are snake_cased along with the literal uses in the
  in-place search engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Constrain embedded card parameter values by the locked parameters

A dashboard's parameter value lookups receive the values of the other
parameters as chain-filter constraints, so the values offered for an
enabled parameter are limited to the rows the locked ones select. The card
lookups had no constraint argument to receive them: `card-param-values`
bound the JWT's params, used them only to check the searched parameter was
enabled and not pinned, then resolved the parameter to a Field and returned
that Field's whole value list.

Give the card lookups the same constraint argument and pass the JWT's
locked values from the embed layer. The argument is optional, so the
authenticated and public routes keep their current behaviour.

`param-type->op` and `param-type->default-options` move from
`parameters.dashboard` to `parameters.params`, so both the dashboard and
card constraint builders can reach them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Snake_case the app-DB query idents the guard rejects

CI caught `queries.models.query`, and a grep for the same shape found four
more sites the earlier sweeps had missed -- they were confined to the
app_db, collections, search and permissions modules. The aliases move to
snake_case in the SQL; where the keys they feed are confined to their own
namespace the readers move with them, and where they have to match a shape
from elsewhere they are renamed back at the read site:

- `queries.models.query` aliases so the app-DB branch matches the keys
  `lib.metadata/card` returns, so it renames rather than propagates.
- `cache.task.refresh-cache-configs` feeds its aliases through a nested
  subquery whose outer `:select` names them, so both levels move, along
  with the readers and the test's expectations.

Also widen `safe-parameter?`: a `query_hash` read back from H2 arrives as a
`java.sql.Blob` rather than a byte array, and the enumeration rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Remove a scratch probe test

Committed by accident in the previous commit -- a throwaway namespace with
printlns, used while checking chain-filter behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Fix the locked-parameter constraints for previews and stale slugs

Three problems with the constraints the card parameter lookups build:

The preview routes passed the sanitized Card, whose `:parameters` has the
locked ones removed, so the constraints were always empty and a preview
offered wider value lists than the embed it previews. They now pass the
stored Card and take only `:embedding_params` from the token, which is what
the published routes already do.

`embedding_params` is keyed by slug and `:parameters` by id, and nothing
keeps them in sync; a locked slug naming no parameter produced no
constraint and widened the values rather than narrowing them. It is now
refused. `param-values-merged-params` refuses the same on dashboards, where
the slug otherwise became a nil key that the constraint builder dropped.

A locked value of `[]` reached the filter builder as a value rather than as
"no value", producing a `:=` clause with one argument.

The constraints also carry the parameter's own `:options` now, as the
dashboard builder does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Allow a qualified argument in a :%fn.arg ident

`:%lower.metabase_field/name` renders `LOWER("metabase_field"."name")` --
Honey SQL splits the ident at the first `.` and formats what follows as
quoted entities, so a `/` there separates the parts of a qualified column
rather than landing in the SQL. Only the function name, which is rendered
unquoted, has to be a bare identifier.

The pattern rejected `/` anywhere after the `%`, which took the database
autocomplete endpoint with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Snake_case more app-DB query idents, and allow the FOR UPDATE modifiers

Found by widening the search: the earlier grep looked for the alias and its
`:select` on one line, so it missed every alias sitting on a continuation
line -- which is most of them.

- `activity_feed`: the recent-views queries alias `database-name`,
  `initial-sync-status`, `entity-coll-id` and `moderated-status`. The keys
  are read only within their own namespace, which builds the API response
  from them under different names, so the readers move too.
- `xrays.automagic-dashboards`: `num-fields` and `list-like?` -- the latter
  loses its `?`, which no snake_case spelling keeps.
- `:skip-locked`, `:nowait` and `:no-wait` are lock modifiers Honey SQL
  matches by name in a `:for` clause, not idents it renders, so they join
  the allow-list rather than being rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Move the query-analyzer's readers to the renamed table id

`query-analyzer/tables-for-native` aliases its table id, and that alias was
snake_cased without moving one of its two readers: `used-tables` still asked
for `:table-id` and so silently collected nothing. Unlike the write side,
which the guard rejects outright, a reader left behind reads nil.

The `tables-for-native` redefs in the tests carried the old shape too, so
they asserted against a result the function no longer produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Revert the HoneySQL ident guard and the renames it required

The guard held every ident in an app-DB query to a plain-identifier shape,
which turned out to have a long tail: each release of it found more query
aliases to rename, and a rename that missed a reader failed silently rather
than loudly, since the reader simply got nil.

This reverts the guard along with every alias rename made to satisfy it,
leaving the parameter-value security fixes, which do not depend on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Harden site-url derivation fix: fail-closed redirect check, discerning test

Follow-ups to gating site-url auto-derivation on authenticated superuser
requests.

- check-sso-redirect: fail closed when site-url is nil. Previously a nil
  site-url left our-host nil, so a redirect with a nil host but a non-nil
  scheme was non-relative yet matched (= nil nil) and passed -- an open
  redirect. Require a known host to match against: (and
  our-host (= (.getHost redirect) our-host)).

- misc-test: bind has-user-setup false in the non-superuser negative case.
  With has-user-setup true (the test-DB default) the old gate
  (or (not has-user-setup) superuser?) was already false for that request,
  so the test passed against the vulnerable code and distinguished nothing.

- misc: rewrite the SETTING SITE-URL block comment and the maybe-set-site-url
  docstring, which still described the removed behavior (site-url set on the
  initial setup request).

- system settings: drop the blank line in the site-url :doc (it made the new
  paragraph render as an indented code block in the generated env-var docs)
  and replace an em-dash with a comma.

* Read a card's parameters the way the rest of the code does

`card-param-constraints` read `:parameters` alone, but a native card can be
described entirely by its template tags and carry none -- which is how the
static embedding e2e fixture builds one. The locked slug then matched no
parameter and the lookup was refused. Use the same either/or source
`get-param-or-throw` uses a few lines above.

A parameter with no value constrains nothing whether or not it still
exists, so the existence check now runs after the empty-value guard rather
than before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u

* Fix jar startup crash when the jar's directory contains a space

find-in-current-jar built a jar: URL by string-concatenating
get-jar-path, but get-jar-path returns the *decoded* filesystem path
(URL -> URI -> .getPath turns %20 back into a literal space). URL.toURI
then throws URISyntaxException on the raw space, killing startup while
loading bundled driver manifests.

Open the jar via the Path-based nio-fs helper instead — Paths don't
round-trip through URI encoding, so spaces are fine. This also stops
closing a filesystem out from under other consumers: the old code, on
FileSystemAlreadyExistsException, grabbed the provider's *cached*
filesystem and closed it in with-open; the Path-based overload creates
an independent uncached instance whose close affects only itself.

Verified against a jar in a spaced directory: the manifest URI is found
and slurpable, and a concurrently-open URI-cached filesystem for the
same jar stays open and registered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Fpja4n6bzdCy4QufM49Hb

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexander Polyankin <alexander.polyankin@metabase.com>
2026-09-01 17:14:27 -05:00
Denis Berezin
84223a0d4d Restyle Number and Trend dashboard cards (UXW-5128) (#81131)
* Restyle Number and Trend dashboard cards (UXW-5128)

* Adjust sizes and tooltips

* Adjust sizes, fix icon

* fix

* Fixes after merge

* Update Loki Snapshots

* Fix multiple comparisons

* Add link to card title, Implement query builder view

* Undo scalar dashcard ellipsis menu styling changes

* Design review fixes

* Fix e2e

* Fix e2e

* Review fixes

* Update Loki Snapshots

* fix

* Self review fixes

* Self review fixes

* Add a "Show comparison value" display setting to the Trend visualization (#81148)

* Add Show comparison value setting to the Trend visualization

* Address review comments

* Standardize ellipsis menu styling across all dashboard cards (#81405)

* Standardize dashboard card ellipsis menus

* Update Loki Snapshots

* Update Loki Snapshots

* Refine dashcard menu button props

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>

* Fix review comments

* Update Loki Snapshots

* Add more tests

* Add keyboard navigation

* Fix borders for dark theme

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Anderson <anderson@metabase.com>
Co-authored-by: Anderson <ands.alves.nunes@gmail.com>
2026-09-02 01:11:13 +03:00
Chris Truter
e24150083f Report dynamic requires which linter cannot verify (#78082)
* Don't crash the modules require hook on dynamic requires

(requiring-resolve (:failing-test-var opts)) has no statically-known
namespace; unwrap-require now returns nil for such forms instead of
falling through, and both callers skip non-symbols. dev.clj has carried
a live :hook crash from this since the hook was added.

* Add a hook regression test for dynamic requiring-resolve

Covers the crash fix: dynamic arguments lint to nothing, static
cross-module violations still report. hooks.* test namespaces join the
outside-the-module-system prefix list.

* Fail closed on dynamic module requires

* Polish module require hook tests

* Assert module hook findings directly

* Document intentional dynamic module requires

* Update module linter ratchet
2026-09-01 21:51:21 +00:00
github-automation-metabase
70f9234106 Auto-update documentation for master (#81742)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-01 21:35:27 +00:00
Nathan Voxland
540817adfe Improve document read/write performance (#76018) 2026-09-01 16:19:58 -05:00
Chris Truter
7f73a7464c Add merge tool for ratchet conflicts (#81341)
* Add a manual kondo ratchet conflict merger

* Split kondo ratchet merge tests

* Document the ratchet conflict merger

* Simplify unlimited ratchet merge policy

* Simplify ratchet merger for final policy schema

* Resolve ratchet conflicts to the stricter policy

Concurrent policy changes now always resolve: the smaller budget wins, a
finite budget beats :unlimited, and a removed entry beats any budget.
Comment exemptions merge on their own. Every stage is read through
read-ratchets and shape-checked before a disabled stage decides the result.

bin/merge-kondo-ratchets reads the index stages by number, so a file
deleted on one side and changed on the other exits nonzero with nothing
staged, and both-sides-add merges against an empty base. It runs from any
directory in the repository and stages only .clj-kondo/ratchets.edn.

mage.merge-kondo-ratchets-test drives the script against real conflicts
in temporary repositories.

* Point the clojure-write skill at the ratchet merge resolver

* Reject malformed stages before merging ratchets

The shape checks move out of read-ratchets into validate-policies, and merge-ratchets runs them on every stage, so a stage with :config-counts [] or :comment-exempt [] is an error rather than an empty set of policies that the merge would read as removals.

* Cover unreadable stages in the merger tests

An empty file, two forms, or a non-map stage now fails the reader instead of merging as no policies; the script leaves such a conflict for a human and stages nothing.

* Reject a non-map ratchet stage before merging

A nil stage passed every field check because dissoc, keys, and the defaults all accept nil, so a malformed stage could merge as an empty set of policies.

* Reject non-keyword linter names

The merge keys on (str linter) and renders the same way, so a string, nil or
symbol name was rewritten as a keyword, collided with one, or produced a file
that no longer read back -- and the script still exited 0 saying it had staged
the merge. Also describe the two sides by git stage: "current" and "incoming"
are inverted during a rebase.

* Run each refused merge once in the tests

leaves-unresolved? and output each ran the script, so every refusal ran it twice
and the "changed nothing" assertion covered a different run from the one whose
message was checked. Read the merged policies with the EDN reader while here.

* Compare the conflicted file, not just its status

A conflicted path reports UU, DU or UD however its contents are rewritten, so
comparing git status alone did not show that a refused run left the file alone.

* Tighten merge-tool docstrings and the ratchet conflict docs

* Compare the index stages of a refused merge too

Porcelain status reports a conflicted path as DU/UU whatever its stages hold, so it alone does not show
that a refused run left the conflict alone. The stages carry each side's blob.

* Spell out the unmerged status codes

UU/DU/UD as bare shorthand assumes the reader knows git's porcelain codes for a conflicted path.

* Clarify the unmerged-state snapshot comment
2026-09-01 21:08:21 +00:00
Nathan Voxland
85f1e975a5 Ensure CSV strings show as strings in excel (#81258) 2026-09-01 15:50:56 -05:00
Chris Truter
bc0200ece0 Enforce and maintain ratchet policies (#81319)
* Add trusted master ratchet shrinker

* Retry ratchet shrink after concurrent master pushes

* Test the check-kondo-ratchets report

* Apply ratchet policy to the check report

* Add severity-based inline ratchet policies

* Update ratchet policy documentation

* Finalize ratchet policy enforcement

* Move seeded linters to bounded ratchets

* Simplify unlimited ratchet policies

* Finish ratchet CI integration

* Document unlimited ratchet policy values

* Keep ignore counts as the policy map

* Validate ratchet policy keys against known linters

Policy keys in .clj-kondo/ratchets.edn must name a clj-kondo built-in from the
pinned version, a linter configured under .clj-kondo/, or an allowlisted
external diagnostic. The check and the fixer fail with one error listing every
unknown name instead of counting it. Config budgets must be non-negative
integers.

Drop :unresolved-require, a linter that never existed, from the semantic-search
ignore and the ratchets file.

* Keep unlimited ratchet policies with no ignores left

An :unlimited entry in :ignore-counts records a decision about the linter, so
the fixer no longer drops it when its count reaches zero. The check and the
fixer print one sorted warning naming such entries; it never fails CI. Bounded
zero budgets are still dropped, and :comment-exempt is unchanged.

* Deliver ratchet shrinking through an automation PR

* Validate seeds and read only tracked kondo configs

The known-linter set now comes from git-tracked `.edn` files under
`.clj-kondo`, so the dependency configs `mage kondo` copies in locally
(gitignored, absent from a clean checkout) no longer make validation
depend on the machine. `--seed` is checked against the same set before
anything is written, and a hand-written bounded `0` with no ignores
left is reported when the fixer drops it.

* Reopen the ratchet automation PR after a close

The "already carries this result" skip now requires an open PR, so a
branch left behind by a PR closed without merging is overwritten and
gets a fresh PR instead of silencing every later run. The compare step
reads PR state with the default token under `pull-requests: read`.

* Let the ratchet automation PR approve and merge itself

Copies the backport recipe: github-actions[bot] approves the PR with the default token (a user cannot approve their own PR, and the automation user owns it), then the automation user enables squash auto-merge. The commit identity is the docs generator's, which merges with that one approval. A ratchets-only change no longer flips backend_all, so the PR waits on the Project tests job alone.

* Describe the self-merging ratchet automation PR

* Expect the ratchet check namespace in the project-test suites

* Read the ratchets file as exactly one map

An empty file, a non-map, or a trailing second form used to read as an empty policy set, which the merger would have staged as the removal of every budget. Tracked kondo configs missing from the worktree (unstaged deletions, sparse checkouts) are skipped instead of failing the scan.

* Skip the uberjar for a ratchets-only change

* Keep fork PRs away from the ratchet automation steps

The head-branch lookup matched any repository's branch of that name, so a fork PR could have reached the approval and auto-merge steps; only an open PR from this repository counts now. Approval and auto-merge key on the open PR rather than on whether the branch was pushed, so a run that failed after opening the PR is repaired by the next one.

* Look up the ratchet automation PR by owner-qualified head

gh pr list caps at 30 results and filtered forks client-side, so enough fork PRs on the same branch name could hide the real automation PR and make the create step fail. The REST head=owner:branch filter is applied server-side and matches only this repository's branch.

* Stop checkout credentials from overriding the automation push token

actions/checkout persists GITHUB_TOKEN as an Authorization header, which takes precedence over the token embedded in the push URL, so the force-push to the automation branch would be rejected.

* Tolerate config budget and exemption reductions in CI

CI already let inline counts fall below their budgets so the shrinker could record them after merge, but a lowered config budget or a stale comment exemption still needed an exact match, so two PRs that each removed a suppression could fail the second merge. Config counts now fail CI only when they grow, stale exemptions never fail CI, and check-kondo-ratchets reports config growth alongside inline growth. Local runs stay exact.

* Say what CI rejects without listing the cases

* Keep actionable ratchets bounded

* Exclude the ratchets file with one pattern

Path filters are OR-ed, so a separate "!.clj-kondo/ratchets.edn" entry matched
every path that was not that file: backend_all became true for every PR, and
no_source_code_changes could never be true. The exclusion has to live inside the
pattern, as modules/drivers does above.

* Arm auto-merge against a known commit

The approve and merge steps keyed on the PR number alone, so nothing had checked
what the fixed automation branch name currently holds. Both paths now record the
sha they pushed or compared, and auto-merge matches it. Also guard the job to
this repository, as the workflow it replaces did.

* Report ratchet validation failures without a stack dump

check read and validated the file, then read it again. Now it parses once and
catches inside, so read-ratchets, validate-linters! and scan all report the same
one-line message. Also: CI=false was truthy in the tests, seeding a recorded
linter with no ignores said "nothing to seed" while dropping its policy, and
lowered-counts rebuilt the seeded set per entry.

* Keep the drift message off stdout

The drifted path prints its findings and then exits; folding it into fail! added
a summary line that check-reports-empty-unlimited-test pins. Split the throw from
the print so only messages the run has not already shown are printed.

* Tighten ratchet docstrings and the generated header

* Guard against standalone negations in the path filters

The patterns in a filter are OR-ed, so a "!..." entry matches every path the other entries don't
instead of subtracting from them, and the filter is then always on. The ratchets exclusion is written
inside one pattern for that reason; this keeps the next one from being written the other way.

* Reuse the automation PR on its ratchet file alone

The extra check that master had not touched ratchets.edn since the branch was cut only bites when master
moved to a different value that shrinks back to the same result, since any change to the result is already
caught by comparing the file. Dropping it means only a shallow fetch of the branch tip is needed.

* Force-push the automation PR when it conflicts

A PR whose ratchet file already matches is otherwise left alone, and a conflict is the one thing that
state cannot clear on its own. Mergeability is computed lazily, so UNKNOWN is polled past and, if it
never resolves, left for the next run.

* Ask once whether the automation PR conflicts

Polling for a definitive answer bought nothing: the next push to master runs this again within the hour,
so a mergeability check still in flight can read as not conflicted and be settled then.

* Say what the reuse comparison is against

git diff FETCH_HEAD -- <path> reads as a commit-to-commit diff. It is against the working tree, which is
the only reason comparing the branch tip answers whether the PR still delivers the right file.

* Fix the circular Filter type alias

Record<string, Filter> is expanded eagerly, so naming it inside Filter's own definition trips TS2456. An
inline index signature is deferred and holds the same shape.

* Prove a ratchets-only change skips the backend filters

* Run the path-filter specs when the filters change

* Use gh CLI to approve ratchet PRs
2026-09-01 20:31:30 +00:00
Ben Grabow
b4f84f1736 Skip some connection validity checks in test (#81725) 2026-09-01 15:22:41 -05:00
Nathan Voxland
52ba78451d Added map-tile-server-allowed-networks setting (#81243) 2026-09-01 15:10:53 -05:00
Ben Grabow
8e4e18f6d5 Don't hit DWH server when no parameters are being set (#81727) 2026-09-01 14:57:28 -05:00
Chris Truter
3c5d1111d7 Run project checks in a separate job (#81318) 2026-09-01 19:37:54 +00:00
Jeff Bruemmer
af549487df docs - ai connections (#81617) 2026-09-01 19:32:12 +00:00
github-automation-metabase
9e2fa97aa1 Auto-update documentation for master (#81619)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-01 15:24:53 -04:00
github-automation-metabase
1d598c7af4 Update translations 2026-09-01 (#81668)
* Update translation sources 2026-09-01

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-09-01 13:22:09 -06:00
Edward Paget
b244f3c7ca Give Quartz a dedicated app-db connection pool to prevent deadlocks (#72261)
* Reuse toucan2's current connection in Quartz to prevent deadlocks

When code inside a with-transaction or with-connection block triggers a
Quartz operation, Quartz would check out a new connection from the pool.
If the pool is saturated, this deadlocks the thread. Now the
ConnectionProvider checks for a bound toucan2 *current-connectable* and
reuses it via a non-closeable proxy that suppresses close/commit/rollback/
setAutoCommit, preventing interference with the outer transaction.

Closes UXW-307

* Address review comments

verify that asking for a connection while one is already bound via
t2.conn/*current-connectable* reuses the identical underlying connection
instead of checking out a new one. Plus fix an `if` that should be a
`when`.

* Revert Quartz connection-reuse proxy

Revert the non-closeable connection proxy that let Quartz reuse the
thread's bound toucan2 connection with close/commit/rollback/setAutoCommit
suppressed. Suppressing those methods breaks Quartz's data integrity:
JobStoreTX only releases its SELECT ... FOR UPDATE row locks on QRTZ_LOCKS
via commit/rollback, so the proxy either held the cluster lock for the
length of the outer transaction or, for autoCommit connections, defeated
the cluster mutex entirely, and made partial job-store writes
un-rollbackable.

The deadlock this was fixing (UXW-307) is addressed in the next commit by
giving Quartz its own connection pool instead.

* Give Quartz a dedicated app-db connection pool to prevent deadlocks

When code inside a with-transaction or with-connection block triggers a
Quartz operation, Quartz checks out a connection from the app-db pool. If
the pool is saturated, this deadlocks the thread (UXW-307). Fix it at the
root: Quartz now draws from its own small c3p0 pool, so job-store
operations never compete with application code for main-pool connections,
and Quartz keeps full control of its connections' transaction lifecycle.

- ApplicationDB now carries a quartz-data-source: a separate pool (max 5,
  min 1, configurable via MB_QUARTZ_MAX_CONNECTION_POOL_SIZE) when
  create-pool? is true, the raw data source otherwise. It inherits all the
  main pool's properties except sizing.
- mdb.connection/quartz-data-source exposes it behind the ApplicationDB
  read-write lock, re-exported via metabase.app-db.core.
- The Quartz ConnectionProvider draws from it; the tracing
  connection-interceptor hook is unchanged.
- Tests cover provider behavior (no reuse of the bound connection, not even
  wrapped), pool construction/sizing, lock-respecting acquisition, and a
  deterministic pool-of-1 deadlock regression test.

* Reject already-pooled data-sources for the dedicated Quartz pool

connection-pool-data-source returns an already-pooled data-source
as-is, so building the Quartz pool from one would silently hand Quartz
the main pool itself -- reintroducing the starvation deadlock the
dedicated pool exists to prevent. No current caller does this (the only
production :create-pool? true site uses the always-unpooled env-derived
data-source), but nothing stopped a future caller from doing it.

Fail fast instead: quartz-connection-pool-data-source now throws on
pooled input, and application-db documents that :create-pool? true
requires an unpooled data-source.

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-09-01 12:09:11 -07:00
Chris Truter
3fd1f63cfc Refresh settings cache before reading LLM providers (#81714)
Refresh the settings cache before reading LLM provider connections

Provider connections all live in the one llm-providers setting, and settings
are cached in process, so an instance only learns about another instance's
edit when its cache next polls — once a minute.

Until then it answers a page load with a list that predates the edit, and it
decides whether an edit is allowed against that same list: a connection
another instance has just deleted still looks present, and the type it
belonged to still reports itself as connected, so a legitimate create is
refused.

Force the staleness check on the five endpoints that read the list. It costs
the one query that reads settings-last-updated, unless something has actually
changed.

This does not make the read-modify-write of the list atomic: two instances
editing different connections at the same moment still lose one of the two
edits. That needs a lock, and is left for its own change.
2026-09-01 20:08:03 +02:00
Egor Grushin
2459fe20ba Carry over picked schedule fields when the frequency changes (#80758)
* Carry over picked schedule fields when frequency changes (GDGT-3096)

* Drop redundant mid-frame test

* Hook normalizeScheduleValue to changeScheduleType

* TDD first

* reviewed

* reviewed

* reviewed

* reviewed

* reviewed

* reviewed

* reviewed

* reviewed

* reviewed
2026-09-01 18:52:23 +01:00
Nicola Mometto
0fec254d6d Compile the LIKE ESCAPE character as h2x/literal so driver inlining can't touch it (#81704)
With a driver bound, [:inline "!"] compiles through driver-specific
inline-value; MySQL emits the hex literal _utf8mb4 X'21', whose implicit
collation (utf8mb4's default, utf8mb4_0900_ai_ci) clashes with the
connection collation when MySQL aggregates collations for LIKE, failing
with 'Illegal mix of collations'. ::h2x/literal compiles through its own
registered formatter to a plain '!' on every dialect, which takes the
connection collation like the pattern parameter next to it.
2026-09-01 17:34:00 +00:00
Andrei Simionescu
ebad52172a Fix AdminSettingInput write race (#80737)
* Don't lose the last change to a setting when a save fails

* Drop a duplicated test title

* Drop the no-op catch on the write chain

* Replace the write queue with a pessimistic disabled state

* Extend the failed-save revert to select and radio inputs
2026-09-01 18:29:21 +01:00
Chris Truter
d7a519ab60 Stop timed-out mage commands and their descendants (#81463) 2026-09-01 19:20:43 +02:00
Riley Thompson
0e0e6ac82b Enforce single-statement impersonated queries on all sql-jdbc drivers (#81653)
* Enforce single-statement impersonated queries on all sql-jdbc drivers (SEC-1189)

Connection impersonation confines a non-admin's native queries to a restricted
DB role, which Metabase activates with a `SET ROLE` prefix. To stop a user from
appending `; SET ROLE <broader>` and climbing out of that role, impersonated
native queries must be a single statement. That guard
(`driver/validate-impersonated-query`) was only overridden for `:postgres` and
`:sqlserver`; every other impersonation-capable JDBC driver (MySQL, Snowflake,
ClickHouse, Starburst) inherited the no-op `:default` pass-through and ran the
stacked query verbatim. On MySQL a stacked `; SET ROLE ALL` activates every role
granted to the connection user, escaping the restricted role entirely.

Define `validate-impersonated-query` once at the `:sql-jdbc` floor so every
impersonation-supporting driver enforces the single-statement validation, and
remove the now-redundant per-driver `:postgres`/`:sqlserver` overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XM9HwBRFsa3ptVAP7UWV14

* clean up

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-01 11:53:13 -04:00
Kevin Kelleher
4348d9373e Improve Card type usage (#81409)
* improve card type usage

* fix a few more Card type casts

* use same gate for visualize another way button as in getVisualizerInitialState so button doesn't silently do nothing
2026-09-01 15:49:36 +00:00
Michael Eby
3e094bd928 docs: reorganize line, bar, and area chart docs (#81108)
* docs: reorganize line, bar, and area chart docs

* Optimized 1 images, saved 199.4 KB (55.6% smaller)

* add content from learn bar chart and histograms articles

* edits from jeff

* remove learn article reference

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-01 10:54:36 -04:00
Ryan Laurie
880bb31835 Treat EoL dates as unsupported (#81639)
treat EoL dates as unsupported
2026-09-01 08:36:22 -06:00
Sébastien
698fd4f1ee Fix flaky terms acceptance in sdk iframe embed setup wizard (#80826) 2026-09-01 14:46:24 +02:00
Sébastien
4dda44859b Move embed wizard copy-event tracking test from Cypress to Jest (#80821) 2026-09-01 14:45:52 +02:00
Sébastien
bd7048574b Gate embedding-hub sandbox column-update test on stepper settling (#80808) 2026-09-01 14:45:18 +02:00
Sébastien
7c8d735d20 Deflake SDK target-collection data-picker test (#81558) 2026-09-01 14:44:45 +02:00
Alexander Polyankin
8b29969f1c Public settings cleanup (#81452)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:19:25 -04:00
Nicola Mometto
90b3db6dec Escape LIKE metacharacters in user-supplied search strings (#81161)
* Escape LIKE metacharacters in user-supplied search patterns (SEC-847)

Name the LIKE escape character explicitly so the escaping also holds on app DBs without a default one

Add h2x/like-substring and h2x/like-prefix and use them at every search sink

* Update in-place search filter tests for escaped LIKE patterns
2026-09-01 12:34:22 +01:00
Chris Truter
39ad1d5167 Keep ratchets off release branches (#81033) 2026-09-01 11:58:19 +02:00
Chris Truter
ba06454a57 Stabilize transform API tests with explicit native-query permissions (#81574)
Grant native create-queries explicitly in transform API tests

The transform API tests grant :perms/transforms explicitly but rely on the ambient db-level create-queries default (:query-builder-and-native) for the shared dataset DB.
That default is fragile: the native level only exists db-level, so any table-level create-queries row written for the group granularizes it down to :query-builder, and re-coalescing can never bring the native level back.
A target table synced by an async transform run during another test's snapshot-restore window leaves exactly such a row, and full-database-permission-for-user takes the most restrictive value across the DB.
When that happens, every test creating or running a native-query transform as a non-superuser 403s for the rest of the run while the MBQL-source tests keep passing (seen on the SQL Server 2022 driver job).

Replace the bare transforms grant with with-transform-db-perms!, which also grants db-level create-queries :query-builder-and-native.
A db-level set deletes every row of that perm type - leaked table rows included - so the grant repairs any polluted state for the test and the scoped restore puts the prior rows back afterwards.
2026-09-01 09:39:30 +00:00
Andrei Simionescu
a1059fff57 BOT-2018: Support Anthropic fast mode for Metabot (#80511)
Admins can turn on fast mode for Metabot when the selected Anthropic model offers it. A rejected fast request falls back to standard speed and holds it for five minutes, so the speed and the cache prefix stay put.
2026-09-01 10:16:58 +01:00
Andrei Simionescu
3d5011e100 BOT-1668: hide the MCP server URL when the MCP server is disabled (#81195) 2026-09-01 10:08:38 +01:00
Timofey Kachalov
08b90a304c Escape and anchor the www. strip in CSP host allowlist (#81182) 2026-09-01 12:06:14 +04:00
Timofey Kachalov
49af71fd44 Validate embedding-app-origins-interactive before it reaches headers (#81166) 2026-09-01 12:05:48 +04:00
Nathan Voxland
e34951e8b7 Improve quoting and literal handling (#80254) 2026-09-01 00:35:52 -05:00
Fraser
8d618a8620 Viz loading slot and small moves (#81625) 2026-09-01 10:56:10 +12:00
YB
72edd1917c Bump mariadb-java-client to 2.7.15 (#81456)
bump mariadb-java-client to 2.7.15
2026-09-01 06:51:56 +08:00
YB
bb4b24c65f Bump httpclient5 to 5.6.4 (#81556)
bump httpclient5 to 5.6.4
2026-09-01 06:45:52 +08:00
Alexander Polyankin
baa2f30a5d Model the request scheme as https/http/unknown (#81592) 2026-08-31 18:22:12 -04:00
Alexander Polyankin
2742202af1 Restrict :param_fields in public/embed endpoints to public Field columns and enabled parameters (#81383) 2026-08-31 18:17:31 -04:00
Romeo Van Snick
2bd8150f2c Give the metadata mirror an enforced public API at metabase/metadata/store (#81272) 2026-09-01 09:12:14 +12:00
Ben Grabow
bd77074097 Migrate H2 -> SQLite sample db even when we're in new-install mode (#81596) 2026-08-31 15:58:10 -05:00
Chris Truter
a93af09ff5 Bump the remaining Hive artifacts to 4.2.1 (#81508)
4.2.1 is published for eight more of the Hive pins in the hive-like driver,
so move them along with hive-common rather than leaving the family split
across two patch versions.

hive-jdbc, hive-service and hive-hplsql stay at 4.2.0 -- no 4.2.1 was
published for those three.
2026-08-31 20:45:01 +00:00
Phoomparin Mano
c5e99eb320 Refresh MCP Apps UI credentials before expiry (#81041)
* fix broken mcp apps authentication due to credentials being cached

* add server-side tool calls to mint ui capability tokens

* rename MCP Apps credential metadata key

* rely on server tools for MCP Apps credentials

* show MCP Apps host errors

* add a test that we do not break existing chats

* fix transient MCP UI credential refresh failures

* combine credential refresh into a single tool

* refactor: extract ui credential constants

* refactor: simplify to a single refresh tool in frontend

* refactor: cleanup

* refactor: simplify unit tests

* refactor: use retry utility

* refactor: remove unused session-id

* chore: copy changes

* chore: rename method to redact-ui-credential

* remove v2 resource prefix

* add test for redacting secret in trace

* Harden MCP UI credential refresh after review (#81454)

* Harden MCP UI credential refresh lifecycle

* Harden MCP UI credential backend contracts

* Accept compatible MCP App resource URIs

* refactor: extract mcp auth hooks and utilities

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-08-31 20:10:50 +00:00
Andrei Chernykh
4b8fca4eb0 Delete no-op resolutions entries (#81408) 2026-08-31 12:48:35 -07:00
Romeo Van Snick
0b035e3f44 Cut four of the common module's back-edges (#80052) 2026-09-01 07:40:16 +12:00
Chris Vandoodewaard
1b7226c27a Require job-level timeout-minutes in GitHub workflows (#80353)
* Add a lint check requiring job-level timeout-minutes in workflows

A job without `timeout-minutes` inherits GitHub's 6-hour default, so a hung
job burns six hours of runner time - and holds up everything queued behind it -
before anyone notices.

actionlint cannot enforce this: its rule set is compiled into the binary, and
`.github/actionlint.yaml` only accepts `self-hosted-runner`, `config-variables`,
and `paths`, the last of which suppresses findings rather than adding them. So
the rule lives in a small yq-based script instead.

It runs in the lint job next to actionlint, gated on the same workflow-file
filter and passed the same changed-file list, and emits `::error file=,line=::`
annotations. Jobs that call a reusable workflow are skipped, since GitHub
rejects `timeout-minutes` there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fold the actionlint path filter into the workflows filter

The `actionlint` filter matched exactly the same paths as `workflows`, and the
lint job was the only consumer of it. Drop the duplicate and point the actionlint
steps at `workflows` / `workflows_files`.

Also give lint.yml's own snowplow job a 10 minute timeout, so the workflow
passes the check it adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop yq shifting reported line numbers on files with header comments

yq slurps a leading comment block before parsing unless
--header-preprocess=false, so every line number it reported was short by the
size of that block. Five workflows start with a comment; on cache-warm.yml the
`warm` job was annotated at line 18 instead of 42.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update .github/file-paths.yaml

Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
2026-08-31 19:20:45 +00:00
dpsutton
fd707ac9a3 Cache parse-approved-origins to stop per-request Invalid-URL log spam (#81126)
`parse-approved-origins` runs on essentially every API response (via
`approved-origin?` in the security-headers middleware, and again in the MCP
origin check) and re-parses the whole CORS-origins allowlist each time. Any
entry that fails to parse -- e.g. a malformed `http://localhost:3000:*` saved
into the SDK/MCP origins setting -- hits `log/errorf "Invalid URL"` on every
request, producing bursts of identical errors around each dashboard load.

The allowlist strings are slow-moving globals, so LRU-cache the parse. Callers
pass a couple of distinct strings (the merged SDK+MCP allowlist, and the
MCP-only one), so a small LRU bound keeps both cached without either evicting
the other, while remaining bounded.


Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:39:30 -05:00
Chris Truter
4f81af5691 Prevent missing search index tables from aborting caller transactions (#81573) 2026-08-31 20:33:20 +02:00
John Swanson
f2cef9e46c More clarity around revoking credentials when "base" entity is gone/deactivated (#80556)
* Fail closed when an OAuth token's client is gone

resolve-access-token looked up the access token, checked expiry, and read the
user id off the token row — but never consulted oauth_client. So an access
token kept authenticating even if the client had been deleted.

Require the token's client_id to still exist in oauth_client, using the
client_id already present in the fetched token data — one indexed lookup on the
bearer path, no schema change. A missing client now fails closed (401).

Tests:
- core-test: token resolves while its client exists, returns nil once the
  client row is deleted.
- session_oauth_test: register the referenced oauth_client so the bearer-bridge
  tests exercise the new client-existence check (previously they minted tokens
  against a client_id with no row).

* Revoke sessions and OAuth tokens on user deactivation

Add an `:event/user-credentials-revoked` event, emitted in the user
`before-update` hook when a user is deactivated, with two subscribers:

- session: delete the user's core_session rows
- oauth-server: mark the user's access and refresh tokens revoked_at

* Pin that an API key's lifecycle follows its synthetic user

An API key authenticates as its own synthetic user (api_key.user_id); the
creator_id column that records the admin who minted it is attribution only and
is never consulted at auth time. So deactivating that admin does not revoke the
key — which is the intended design: keys are org-level service credentials,
visible to and revocable by any admin from the API-key page, independent of who
created them. Revoke a key by deactivating its synthetic user
(DELETE /api/api-key/:id), not the human who minted it.

No behavior change. Add a regression test that pins this: a key survives its
creator's deactivation (200) but is revoked when its own synthetic user is
deactivated (401), so the two paths cannot be conflated by a future creator_id
join. The DELETE-endpoint revocation path is already covered by api-keys-work-e2e.

* Register the credentials-revoked topic in the events hierarchy

The events system validates topics against its own hierarchy in
metabase.events.hierarchy, not Clojure's global one, so the plain
derive calls left :event/user-credentials-revoked unregistered and
publish-event! threw a 500 whenever a user (or API key's synthetic
user) was deactivated. Use events/derive! like every other subscriber.
2026-08-31 11:29:45 -07:00
Chris Vandoodewaard
596e0c0f71 Fix for LTS tag causing minor release failure (#81038)
* filter lts versions when finding latest from tags

* always skip lts tag since it isn't valid for finding latest versions

* add guard for valid version to release-log

* add tests

* fix actionlint

* clean up/fix logic in release-log

* Keep returning the major version for rolling .x tags

getMajorVersionFromRef now recognizes v0.56.x / v0.51.2.x style tags in
addition to valid version strings, so only lts and non-version tags get
an empty string back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:28:27 +00:00
Kevin Kelleher
8c3ea6f40b Remove Explorations dimension anchored blocks (#80554)
* remove dimension anchored blocks from FE

* Remove dimension-anchored blocks from the backend

Research-plan groups are now always a metric sliced by dimensions; the
dimension-anchored shape (one dimension slicing several metrics) is gone:

- add_research_groups: drop anchor/dimension_id/metric_ids, require metric_id
- remove_from_research_plan: members lose metric_ids; dimension_ids required
- research-groups validation: metric-only, anchor dispatch removed
- blocks-tree: no :type on nodes; "By <dimension>" headings and the
  same-name disambiguation machinery deleted
- BlockSelection/ExplorationBlockNode schemas lose :type; the
  exploration_block.type column is no longer written (left in place,
  nullable)
- explorations.selmer prompt rewritten for the single group shape

Legacy dimension-anchored rows keep rendering (as metric-headed blocks);
pinned by the new blocks_test regression test.

* more FE cleanup, fix tests

* Drop exploration_block.type column

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-31 17:14:01 +00:00
bryan
72987c24bc Validate LDAP server certificates and normalize synced attributes (#81039) 2026-08-31 10:43:37 -06:00
Oleksandr Yakushev
105262e022 Bump Toucan2 to 1.0.575 (#81449) 2026-08-31 19:22:45 +03:00
Andrei Chernykh
883ff5c9ac Remove unused webpack deps, fix rspack invalidate path (#81390) 2026-08-31 09:20:25 -07:00
Andrei Chernykh
fa7e1e598a Remove json-to-pretty-yaml (#81085) 2026-08-31 09:11:28 -07:00
Andrei Chernykh
94f0aec2e9 Replace @cypress/skip-test with a local helper (#80947)
* Replace @cypress/skip-test with a local e2e helper

* Document --spec requirement in the e2e-test skill

* Tidy skip-test helper comments and skill doc wording
2026-08-31 09:08:47 -07:00
Chris Truter
2da893377b Fix the cancellation race in process-userland-query-test (#81560)
* Wait for the query to reach the reduce step before cancelling in cancel-test

cancel-test gave the query a fixed 100ms head start before future-cancel and
assumed it would be sleeping inside *reduce* by then. Only an interrupt that
lands inside *run*'s try posts ::cancel to the canceled channel, so whenever
pre-pipeline setup took longer than 100ms the future died silently during
setup and the test timed out waiting for the cancel message.

That made the test flaky under CI load and deterministically red when run in
isolation: on a fresh JVM the first query's setup takes seconds, so the
granular-rerun step (which re-runs only the failed test) could never pass it,
pinning any PR that flaked onto it. Synchronize on a promise delivered from
*reduce* instead of sleeping.

* Block on a latch instead of sleeping in *reduce*

The 1000ms sleep was a second timing assumption: the cancel had to arrive
within it or the reduce would complete and save the execution. Await a latch
nobody opens instead — the interrupt wakes it just like the sleep, with no
window to miss. The 30s timeout only bounds the worker thread if cancellation
is genuinely broken.

* Use a never-delivered promise and drop the dead *result* call

A promise is the same CountDownLatch under the hood (interrupt propagates
identically from deref) and matches the started promise next to it. The
*result* call after the block was unreachable on the happy path — the
interrupt throws out of the deref — and pointless on the timeout path, where
the test failed 28 seconds earlier.

* Collapse the port/val channel assertions into one

A timeout or closed channel takes nil, which fails against ::cancel on its
own — no need to name the winning port. Also fixes the doubled word in the
testing string.

* fix cancellation test cleanup race

* Clarify cancellation test scope

* Strengthen cancellation metadata assertion

* Polish cancellation test formatting
2026-08-31 17:49:57 +02:00
Mihael Konjević
e815f913d1 Update Metabot data-source guidance for stale references and curation signals (#78248)
Clean up stale, contradictory, and unreachable guidance in Metabot prompts and skills

- Remove references to tools/params that never existed (`search_tables`, `search_transforms`, `get_report_details`, doc-search tool, `collection_id`/`database_id` on search, `update_dashboard` mutations, `is_library_member`, dead `source_tables` param, duplicate Slackbot tool)
- Align per-profile guidance with actual tool wiring: SQL-only profile has no notebook/metric/chart tools, Slackbot has no `read_resource`, `construct_notebook_query` schema differs by profile, SQL query-link behavior differs by tool
- Fix `read_resource`: pagination bug (server now returns a ready-to-use `next-page-uri` instead of broken `?page=N` guidance), curation-signal comparison, container/document listing
- Fix a Selmer `{% safe %}` vs `{% verbatim %}` bug that silently blanked all `{{#model_id}}` examples in the SQL profile prompt
- Document previously-missing required arguments across several tools (`create/edit/replace_sql_query`, `edit_chart`, time-clause `unit`, etc.)
- Fix `document_schema_collect`'s unreachable stop instruction and missing `database_id`
- Fix `list_available_fields` fallback for saved questions
- Add regression tests locking in the template-rendering and pagination fixes
2026-08-31 17:45:35 +02:00
Mahatthana (Kelvin) Nomsawadi
63057ee485 Key guest embed tokens per instance instead of one shared slot (#81351)
* key guest embed tokens per instance instead of one shared slot

Concurrent guest StaticQuestion/StaticDashboard embeds under one
MetabaseProvider wrote into a single Redux rawToken slot, so the
last-mounted instance's token won for all of them. Key by a stable
per-mount instance id instead.

* refactor: name the guest token key mountId

instanceId read as the Metabase instance, and collided with
EnsureSingleInstance's own instanceId. Also simplify the two reducer
cases the fix rewrites, which Immer lets us assign into directly, and
drop a !mountId guard that currentToken already covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkZ5rUUDeeWN9GU2wuEUHk

* test: drop the multi-dashboard guest token spec

It passed with the whole fix reverted to master: asserting each endpoint
was called at least once is already true there, because every mount's
first render uses its own token prop and only the re-render collides. The
name also implied multiple StaticDashboards work, which they never have —
they share one global state.dashboard slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkZ5rUUDeeWN9GU2wuEUHk

* test: cover a guest refresh that switches resource

Every existing refresh case reissues a token for the same resource, so
none of them notice which token the component ends up using — PR #81240
flipped that precedence and CI stayed green. Switching to a question that
takes different parameters is what makes the choice observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkZ5rUUDeeWN9GU2wuEUHk

* fix: drop a guest mount's token when it unmounts

The iframe embed route re-keys its children when the token changes, so a
parent-side token update remounts the component with a fresh mountId.
Nothing removed the old entry, so Object.keys(...)[0] in
getOrRefreshGuestSession kept picking the dead mount and every request
was rewritten with its stale token. Once that token expired, the refresh
wrote back to the same dead key, so the map never recovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016stkr2R8EEPxp5gbyqDjdR

* test: cover a guest remount with a new token

Mounts a guest question, re-keys it with a second token the way the
iframe route does, and asserts the query goes out with the new token.
Fails without the unmount cleanup: no request reaches the new token's
url at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016stkr2R8EEPxp5gbyqDjdR

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:21:19 +00:00
github-automation-metabase
38cc09d133 Auto-update documentation for master (#81576)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-31 11:03:40 -04:00
John Swanson
ba823a5488 Include every attribute remapping in the sandbox FieldValues fingerprint (#81040)
The per-user sandbox fingerprint (the advanced-FieldValues cache key, and
the sandbox component of data-access tokens) narrowed the hashed login
attributes to remappings whose dimension target resolved to a field of the
sandboxed table itself. But the query processor applies *every* attribute
remapping when it runs the sandboxed query, including ones targeting
columns of tables joined inside the sandbox card, or columns referenced by
name. Those remappings were silently dropped from the fingerprint, so two
users whose sandboxes differed only by such an attribute hashed to the
same field_values row and were served each other's cached values (and
could read each other's stored exploration results via the colliding
data-access token).

Fix: fingerprint every key in attribute_remappings, regardless of what
its target resolves to. This exactly mirrors the set of attributes the QP
consumes, keeps unrelated login-attribute changes from busting the cache,
and leaves hashes for own-table-only sandboxes (the common case)
unchanged. Existing colliding rows become unreachable under the new hash
and are reaped by the delete-expired-advanced-field-values sync job.

Fixes SEC-874.
2026-08-31 07:41:06 -07:00
Ben Grabow
b7d74aab6d Fix order-dependent transform permissions in tests (#81404)
* Small correctness and hardening fixes

A handful of independent, low-risk fixes bundled together — each too small to
ship on its own, each a behavior improvement that also closes a minor
hardening gap.

Reduce DB load / avoid unconditional work:
- Settings cache: a settings-last-updated cookie now triggers a cheap
  single-row staleness check and reloads only if the cache is genuinely
  behind, instead of unconditionally reloading the whole settings table.
  Exposes restore-cache-if-needed! with a :force-check? option as the shared
  "reload if actually stale" primitive the cookie path now uses.

Reduce log noise:
- CORS origin validation runs on every request's raw Origin header, so it now
  parses that client-supplied value with the silent try-parse-url rather than
  the variant that logs "Invalid URL" at ERROR — a malformed Origin no longer
  fills the operator's error log. Parsing behavior is otherwise identical, so
  no CORS allow/deny decision changes.

Correctness (case-insensitivity):
- forgot_password throttles on the lower-cased email, matching the
  case-insensitive account lookup, so User@x and user@x share one bucket
  rather than each getting a fresh allowance.

Add a missing throttle:
- PUT /api/user/:id/password throttles the old_password re-auth check, which
  previously had none, bringing it in line with the other credential
  endpoints.

* Set create queries permission explicitly for transforms tests

---------

Co-authored-by: dan sutton <dan@dpsutton.com>
2026-08-31 09:32:26 -05:00
Jeff Bruemmer
e8b8ad1a88 docs - script to generate page for supported AI providers (#81101) 2026-08-31 09:57:51 -04:00
Jeff Bruemmer
a1c217c51b docs - embedded ai chat updates (#80940)
* ai chat

* edits

* edits

* split

* revisions

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>

* revisions

* custom error image
2026-08-31 09:57:02 -04:00
Alexander Polyankin
629fd1fee0 Encrypt only settings that are secret or integrity-critical (#81506) 2026-08-31 09:37:14 -04:00
Chris Truter
f332a4e4b2 Stamp private ECR images with their Git commit (#81557) 2026-08-31 15:05:46 +02:00
Mihael Konjević
1c7ac88006 Respect sandboxing, impersonation, and routing in Metabot metadata (#80723)
* Withhold fingerprint statistics from row-restricted users in get_field_values/read_resource

Fingerprints were read from the shared Field row and refingerprinted on demand with no
restriction gating -- the same leak #436 fixed in llm/context.clj, left open here. Gate
both behind a fail-closed, per-database-batched row-restricted-table-ids check, resolving
each Field's owning table canonically rather than trusting caller-supplied metadata.

Also guard list_available_fields, viewing-context entity lookups, and typed-schemas
table/card selection against destination-database resources, and apply a missing
permission-filter-columns pass to card-details' FK targets.

* Widen LLM schema-context row-restriction handling to sandboxing and routing

row-restricted-user? only checked impersonation and failed open on a feature/license
lapse. Replace it with metabot's fail-closed row-restricted-table-ids, applied per table so
one restricted table doesn't blank out fingerprints for the whole response.

Also filter a table's own sandbox-restricted columns (previously only FK targets were),
constrain mentioned/FK-target tables to the requested database, add a database read-check,
and cap+log restricted-table field-values fetches per table.

* Cover destination-DB exclusion in typed schemas; quiet expected 404s in viewing context

Adds test coverage proving select-schema-cards/select-tables actually exclude
tables and cards backed by a destination (routed) database from typed-schema
generation -- the only guard on that path, previously unverified.

Also stops fetch-and-format logging every expected 404 (missing entity, or a
destination-db routing guard) at ERROR level; it now logs at DEBUG and still
falls back to the simple entity rendering, same as before.

* Quiet expected destination-DB 404s in get_metadata

safe-fetch was logging every fetch failure at error level, including
the routing-destination-database 404s from check-table-resource-database
/check-card-resource-database, which are an expected outcome rather
than a bug. Matches the debug-level treatment already given to the
same case in agent/user_context.clj.

* Style pass: trim design-rationale docstrings, shorten oversized fns, wrap long lines

- row-restricted-by-db: move the retry-ladder rationale out of the docstring and
  onto the try/catch lines it explains.
- permission-filter-columns: extract column-table-id/field-visible?/caller-joined?/
  referenced-field-visible?/column-visible? as top-level private fns instead of
  closures, bringing the function back under ~20 lines.
- build-schema-context: extract drop-or-enrich-fingerprints.
- Wrap a handful of lines pushed past 120 chars by the with-cache nesting and new
  test fixtures.

No behavior change; same 83 tests (315 assertions) still pass.

* Fail closed on unresolvable table IDs in row-restricted-table-ids

A table ID that couldn't be resolved to a Table row silently fell out
of the per-database restriction map and defaulted to unrestricted,
contradicting the fail-closed contract this function documents.

* Log restriction-probe failures in row-restricted-by-db

All three exception paths in row-restricted-by-db were silent, so an
impersonation/routing attribute failure left no trace of why every
fingerprint for a database fell back to the per-table restriction path.

* Handle unrecognized get_field_values data_source gracefully

mu/defn schemas aren't enforced in production, so an LLM-supplied
data_source could reach a case with no default clause and throw
uncaught, failing the whole tool call. Widen the case (and its schema)
to cover "question"/"report" like field-values already does, and give
both the permission-check case and field-values a graceful fallback
for anything else.

* Return an agent-facing 404 for a field-id with no resolvable table

check-column-table-perms! used api/check-404, which throws without
:agent-error?, so handle-agent-error rethrows it and fails the whole
tool call. This is reachable from ordinary stale result_metadata (a
dropped column, or serdes drift), not just a malicious input.

* Apply restricted-field-values cap after the eligibility filter

The per-table cap on restricted-table FieldValues fetches was applied
to raw columns before filtering to those that should have FieldValues,
so ineligible columns ahead of an eligible one in the raw list could
consume its slot in the budget and leave it without values. Filter
first, then cap -- grouping by the persisted Field's own table_id
while at it, consistent with the permission checks elsewhere in this
namespace.

* Move database read-check out of the extract-sources/generate-sql prelude

fetch-accessible-tables-with-columns required database read access
unconditionally, so /extract-sources hard-403'd even when a caller only
wanted its independent :card_ids behavior. generate-sql is the path
that needs the 403, so move the check there via build-schema-context
and leave get-tables-with-columns (extract-sources) to return an
empty result instead.

* Exclude destination-DB cards and fieldless models from answer-sources

get-metrics-and-models had no destination-database guard, so a card
backed by a destination (routed) database could still reach
list_available_data_sources -- the same gap already closed for
get_metadata. Also omit models whose :fields end up empty after
permission filtering (no view-data access to their table) instead of
listing them as data sources with nothing queryable.
2026-08-31 14:44:47 +02:00
Rodrigo López Dato
7d46650cad Don't use app DB pool to probe for initial connectivity (#81271)
Prevents app DB misconfigurations from timing out by failing immediately
2026-08-31 08:17:54 -03:00
YB
24f4aec505 Bump hive-common to 4.2.1 in the hive-like driver (#81460)
bump hive-common to 4.2.1 in the hive-like driver
2026-08-31 17:15:55 +08:00
YB
c839a31539 Pin reactor-core 3.8.7 in the Snowflake driver (#81462)
pin reactor-core 3.8.7 in the snowflake driver
2026-08-31 17:15:03 +08:00
YB
3866917fba Bump jsoup to 1.23.2 (#81457)
bump jsoup to 1.23.2 and pin it in the snowflake driver
2026-08-31 17:14:28 +08:00
YB
1866aaccd9 Bump sshd-core to 2.19.0 (#81461)
bump sshd-core to 2.19.0
2026-08-31 17:14:17 +08:00
YB
52fa5626a2 Bump httpclient5 to 5.6.3 (#81459)
bump httpclient5 to 5.6.3 and pin it in the clickhouse and databricks drivers
2026-08-31 17:14:05 +08:00
YB
ad47b4094b Bump Netty to 4.1.137.Final / 4.2.17.Final in driver deps (#81458)
bump netty to 4.1.137.final and 4.2.17.final in driver deps
2026-08-31 17:13:55 +08:00
Alexander Polyankin
d568f38f1a Encrypt plaintext values of encrypted-at-rest columns on startup (#81498) 2026-08-31 00:12:53 -04:00
Alexander Polyankin
0c54590106 Read settings without the cache while reconciling their encryption (#81491) 2026-08-30 23:02:41 -04:00
Alexander Polyankin
840faeb408 Reconcile settings encryption with the registry on every startup (#81478) 2026-08-30 21:38:46 -04:00
Alexander Polyankin
172bc22044 Name the source in strict-decrypt read failures (#81471) 2026-08-30 17:25:28 -04:00
Alexander Polyankin
c5878f2509 Encrypt legacy plaintext rows of :setter :none settings (#81464) 2026-08-30 15:25:34 -04:00
Ben Grabow
1e78c186c0 Improve log output in DWH GC job (#81447) 2026-08-29 04:05:59 +00:00
Alexander Polyankin
6b9bcaca8e Never encrypt the app DB on startup when there is existing data; add enable-encryption command (#81237) 2026-08-28 22:05:19 -04:00
John Swanson
418fbf1bf9 Treat API-key users consistently across user endpoints and attributes (#78976)
* Treat API-key users consistently across user endpoints and attributes

API keys are backed by rows in core_user with type = 'api-key'. Most
/api/user endpoints already restrict themselves to personal users, but
a few didn't, and user attributes were resolved for all user types.
Align everything on the rule that only personal users are managed via
/api/user and only personal users carry attributes:

- GET /api/user/:id and PUT /api/user/:id now 404 for non-personal
  users (API-key users and the internal user), matching the sibling
  endpoints (password, reactivate, password-reset-url, delete, modal)
  and the list endpoint's existing type = 'personal' filter. API keys
  are managed via /api/api-key instead.
- EE PUT /api/mt/user/:id/attributes now 404s for non-personal users.
- user/add-attributes only resolves attributes (tenant + jwt + login)
  for personal users; other types resolve to {}. Sandboxed queries run
  by a non-personal user therefore report a missing user attribute
  rather than reading attributes stored on the user row.

No migration: any pre-existing login_attributes on API-key rows are
left in place but no longer take effect. GET /api/user/current still
works for API-key authentication, with an unchanged response shape.

Scoped programmatic access as a first-class feature is tracked
separately in #72959.

* Provide a schema for `/api/user/current`

* Update kondo ratchet budgets

- :metabase/validate-defendpoint-has-response-schema 441 -> 440, since
  GET /api/user/current now has a response schema.
- :discouraged-var 100 -> 101 for the mt/with-temp ignore in the new
  sandboxing test: it creates a :model/User, which metadata providers
  do not support, matching the exception documented in the linter
  message and the file's existing ignores.

* CR resp - throw on missing `:type`
2026-08-28 15:41:39 -07:00
Ryan Laurie
7293988a96 dont refresh dynamic snowflake tables (#79846)
# Conflicts:
#	modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj
2026-08-28 22:32:27 +00:00
Timofey Kachalov
e925a8df94 Temporarily disable Shoppy sample app tests (#81416) 2026-08-28 15:53:59 -06:00
Fraser
a0d5c5a0c7 Create the viz-core module (#80786) 2026-08-29 09:53:45 +12:00
Ryan Laurie
441cfa66ef Add Databricks Workspace Sharding (#81241)
* add env sharding for databricks

* follow the rules

* kick ci

* kick ci

* make the console output nicer
2026-08-28 15:15:46 -06:00
Timofey Kachalov
01add7e6e2 Fix per-table permission queries in db metadata endpoint (#81397) 2026-08-29 00:54:55 +04:00
appleby
e023152e90 Send finish-reason length for a Gemini MAX_TOKENS truncation (#80959)
* Send finish-reason length for a Gemini MAX_TOKENS truncation

Translate Gemini finishReason values to AI SDK v5 finish reasons and add them to the `:usage` chunk, as the other
adapters do. `MAX_TOKENS` now reports `:finish-reason "length"` instead of an in-stream error part, so the agent loop
stops cleanly and the client can offer to continue.

* Add missing image-related stop-reasons to gemini provider

The image related stop reasons should happen in practice since we only do text generation, but add them for
completeness.

https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/rest/v1/GenerateContentResponse#FinishReason

* Add stop-reasons from the Gemini Developer API as well

https://github.com/metabase/metabase/pull/80959#discussion_r3867216474
https://ai.google.dev/api/generate-content#FinishReason

* Review feedback: only add synthetic usage part for finish-reasons-without-error

* Review feedback: name the early-stop-without-error subset
2026-08-28 13:27:23 -07:00
Anderson
e9235e2983 Fix flaky remote-sync branch-switch e2e test (#80397)
* Deterministically gate remote-sync branch-switch test on its async boundaries

* Sign in before activating the token in the admin settings describe

* Wait for collection items responses before asserting synced content

* Reword test comments per writing conventions

* Trim comments and move timeouts to request/test-level options

* Remove test comments and restore waitForTask retries cap

* Fix H2 deadlock between the post-import search reindex and readers

* Additional cleanup

* Cover the in-task H2 reindex with a unit test

* Consolidate remote-sync e2e setup into a shared helper
2026-08-28 16:54:53 -03:00
appleby
5bf02e30d7 Support Anthropic partner models via the Google provider (#80133)
* Support Anthropic partner models via the Google provider

Anthropic models are served by `streamRawPredict` with an Anthropic Messages body, so route them through the new
`metabase.metabot.self.google.raw-predict` namespace, which wraps the existing claude adapter. Connecting probes the
model's own `streamRawPredict` route with an empty body: Google resolves the credential, project, location and model
from the URL before Anthropic sees the body, so an Anthropic-shaped 400 validates all four without spending a tokens.

* Title-case the Gemini model display names

To match the convention for all other providers and models.

* Translate mid-stream failures on the Google provider

`google-raw` handed back the SSE reducible unwrapped, so a failure while the agent loop consumed it escaped the
adapter untranslated and surfaced as a raw 500. Wrap it in `core/reducible-with-api-errors`, like every other adapter.

* Carry the probed model in :learned-config

We already reported `:probed-model` as sibling of `:learned-config`. Nest it inside so that it gets saved with the
learned config so we can use it for subsequent connection probes for the Google provider. This tends to be a much
better guess for what model we should use for the connection test rather than just using the first model in the fixed
models list, since there is no guarantee that model is actually present in the currently configured location.

* Probe a Google connection against the model it serves

The listing path re-probed whichever model the fixed catalog names first, so a connection at a location serving Claude
but not Gemini 3.x saved fine and then reported itself broken the moment its cache entry expired. Probe the model the
connection is known to serve instead — the one Metabot is pointed at, or the `:probed-model` its last successful probe
recorded — and fall back to the catalog's first model only when it has neither.

* Start the edit form on the model the connection was probed against

Reopening a connection while Metabot points elsewhere reset the Model picker to the type default and submitted that as
the probe target, so a Google project whose location serves Anthropic partner models but not Gemini had a no-op edit
rejected against credentials that work. Fall back to the connection's own probed-model, when the catalog still offers
it, before the type default.

* Ensure the obfuscated-value? regex matches values ending in newline

Enable "dotall" mode for the obfuscated-value? regex to support obfuscated values where one of the last two chars is a
newline. For example, the contents of JSON service account key used for Google cloud auth or similar.

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/regex/Pattern.html#DOTALL

* Review feedback: validate-google-model!

* Review feedback: use proposed-model in vllm list-models

* Review feedback: ensure new-value is not an obfuscated-value? in set-single-provider-setting!

* Add comment about `model->family` dispatch

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-08-28 11:54:39 -07:00
John Swanson
cb7c71d1ba Allow embedding readable cards in documents without adhoc query-authoring perms (#80775)
Allow embedding readable cards in documents without query-authoring perms (UXW-5037)

Saving a document clones every embedded existing card into a document-owned
card row. The clone path ran check-run-permissions-for-query on the cloned
query, which for a native query demands create-queries=query-builder-and-native
on the whole database. A user who could view (and run) an existing native
question therefore could not save a document embedding it, losing their work
with a vague 403.

That check is an authoring gate, but the embedding user is not authoring a
query: the query text comes from an existing card row they passed a read
check on -- the client supplies only a card ID, never SQL. Running the clone
is still gated by the usual runtime permission checks (card read perms,
result-metadata view-data, block perms), which are the same checks that gate
running the source card, so cloning grants no new runtime capability. This
mirrors POST /api/card/:id/copy, which likewise clones a readable card
without an authoring check.

Split the documents-API card creation helper into create-card! (still
checked; used for client-supplied draft cards) and clone-card! (used when
cloning embedded cards and when copying a document's cards). Authoring a new
query -- via draft cards or by editing a cloned card's query through
PUT /api/card/:id -- still requires the appropriate query permissions.
2026-08-28 11:35:40 -07:00
Ben Grabow
2bfd318907 Dwh gc parallel (#81382)
* Bump timeout to 60 minutes

* Run redshift GC in parallel, with 1 worker per cluster node

* Run snowflake GC with N=8 parallelism

* Record dataset census before GC sweep

* Satisfy the linter
2026-08-28 13:27:49 -05:00
John Swanson
4c62275732 Hide all tenant groups/users from perms graph api (#74973)
* More consistently hide tenant users/groups

There were a few locations where we weren't correctly hiding the
existence of Tenant Groups such as "All Tenant Users" when the setting
was turned off. This also hides tenant *users* from the API surface.

* allow specifically showing tenant users

I realized that there was a possible regression in the last commit. We
were hiding tenant users across the board. This meant that a workflow
that was previously possible was not possible anymore:

- an admin turns off the tenant feature or disables tenants

- then the admin uses the API to reactivate some tenant users and turn
  them into internal users.

This is probably super niche anyway but it seems nicer to allow `GET
/api/user` to show these users, IF you pass the
`tenancy=external&status=all` flags.

Then you can reactivate the user and detach them from the tenant.

This is all API only so it's probably academic, but figured I'd keep the
door open.

* fix tests

* fix e2e tests

* Move tenant tests to enterprise tenant test namespaces

* Address review feedback on tenant-group hiding

- Rewrite hidden-tenant-group-ids to intersect the input IDs with all
  tenant-group PKs in memory instead of building an unbounded IN clause
- Return 404 from PUT/DELETE /api/permissions/group/:group-id for hidden
  tenant groups, matching GET /api/permissions/group/:id
- Assert GET /permissions/graph/group/:id succeeds when use-tenants is on
- Remove duplicate create-tenant-group-requires-use-tenants-test
2026-08-28 11:26:13 -07:00
John Swanson
2971a5b944 Strip client-supplied :card before hydrating in update-dashcards! (#73104)
`dashboard/update-dashcards!` calls `(params/dashcards->param-field-ids
(t2/hydrate new-dashcards :card))` to compute the field IDs referenced by
parameter mappings. `t2/hydrate` is a no-op when `:card` is already present
on a dashcard, so if the client `PUT`s `/api/dashboard/:id` with a dashcard
that includes a nested `:card` (the standard shape from `GET`), that object
is forwarded verbatim into `param-target->field-id`.

That function validates its `card` arg against `:metabase.queries.schema/card`
(added Oct 2025), which requires keywordized metadata (`:type/Category`,
`:source/table-defaults`, ...). After JSON round-trip through
`cy.request`, those values are strings, so the request 500s with
`Invalid input: {:result_metadata [{:semantic_type [...]}]}`.

`dissoc :card` before the hydrate call forces a fresh DB select with
properly-typed metadata, keeping the on-demand-DB field-value update path
unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-28 11:24:38 -07:00
dpsutton
439b8e2af5 Small correctness and hardening fixes (#81374)
A handful of independent, low-risk fixes bundled together — each too small to
ship on its own, each a behavior improvement that also closes a minor
hardening gap.

Reduce DB load / avoid unconditional work:
- Settings cache: a settings-last-updated cookie now triggers a cheap
  single-row staleness check and reloads only if the cache is genuinely
  behind, instead of unconditionally reloading the whole settings table.
  Exposes restore-cache-if-needed! with a :force-check? option as the shared
  "reload if actually stale" primitive the cookie path now uses.

Reduce log noise:
- CORS origin validation runs on every request's raw Origin header, so it now
  parses that client-supplied value with the silent try-parse-url rather than
  the variant that logs "Invalid URL" at ERROR — a malformed Origin no longer
  fills the operator's error log. Parsing behavior is otherwise identical, so
  no CORS allow/deny decision changes.

Correctness (case-insensitivity):
- forgot_password throttles on the lower-cased email, matching the
  case-insensitive account lookup, so User@x and user@x share one bucket
  rather than each getting a fresh allowance.

Add a missing throttle:
- PUT /api/user/:id/password throttles the old_password re-auth check, which
  previously had none, bringing it in line with the other credential
  endpoints.
2026-08-28 13:13:31 -05:00
Ben Grabow
8861e84d3f Cache clojure cli download (#81369) 2026-08-28 12:24:22 -05:00
Ben Grabow
0cad99631a Fix flaky query executions test (#81366)
* Flush other executions before test. Divert other executions during test.

* Flush after closing the batch ingress path.

* Remove extraneous changes

* Remove more extraneous changes
2026-08-28 12:04:12 -05:00
Nathan Voxland
393086c44a Don't allow reset password for deactivated accounts (#78699) 2026-08-28 11:32:14 -05:00
Ryan Laurie
7e3ab7a682 Cleanup data warehouses nightly (#79849)
* cleanup data warehouses nightly

* rename

* don't reinvent wheels

* simplify

* more

* cleanup

* updates

* Absorb failures at the redshift cluster level and at the driver level

* Extract old dataset name pattern

* Update ratchets

* Merge MB_REDSHIFT_TEST_HOSTS and MB_REDSHIFT_TEST_HOST when both are set

* Allow ad-hoc dispatch and report on outcomes of gc sweeps

* Don't run on forks

* Update ratchets

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ben Grabow <ben.grabow@gmail.com>
2026-08-28 10:09:56 -05:00
Sébastien
e56d3de794 Wait for the content-translation dictionary in the SDK rerender spec (#81352) 2026-08-28 16:55:31 +02:00
github-automation-metabase
c06c3b8f16 Make isRenderedWithinViewport retry until element settles in viewport (#77401)
Make isRenderedWithinViewport retry until element settles in viewport

The command read getBoundingClientRect() once, so a caller asserting right
after a scroll-into-view action (focusing a recently moved dashboard filter)
could observe the element mid-scroll (top ~1647px vs 800px viewport) and fail.
Wrapping the geometry checks in .should() lets Cypress re-read the rect until
it lands within the viewport.
2026-08-28 16:19:42 +02:00
Nicola Mometto
190dcf1bd0 Compute longest common substring length with an iterative O(n*m) pass (#80985)
* Compute longest common substring length with an iterative O(n*m) pass

* Add perf edge-case tests for largest-common-subseq-length
2026-08-28 14:55:29 +01:00
Mihael Konjević
4fc305fa64 fix: recognize lib/*-stripped pMBQL in ->legacy-mbql, restore edit-chart's query-id (#78798)
* fix: recognize lib/*-stripped pMBQL in ->legacy-mbql, restore edit-chart's query-id

->legacy-mbql gated pMBQL->legacy conversion on :lib/type alone, so a query that
lost its :lib/* keys (e.g. round-tripped through the frontend's viewing context)
fell through unconverted. The raw pMBQL then got embedded in /question# links,
crashing the frontend with "Stage 0 does not exist". Detect pMBQL structurally
(:lib/type or :stages) and normalize against the query schema before converting.

Also add the query-id edit-chart's result was missing, so extract-charts can
capture edited charts into conversation state and chart->xml stops rendering
an empty query-id attribute.

* fix: preserve edit-chart's query in memory, don't fail turn on link conversion

edit-chart's result set :query-id without :query, so extract-charts (which
requires both to fire, then rebuilds :queries from :query alone) overwrote
the chart's real queries with [nil] on the next update-memory call.

->legacy-mbql could also throw instead of degrading: normalizing a
:lib/*-stripped query mints fresh :lib/uuids that don't match positional
aggregation/expression refs elsewhere in the query (e.g. an order-by on the
query's own aggregation), failing conversion inside the streaming transducer
and taking down the whole agent turn instead of just producing a bad link.
Falls back to the raw query on conversion failure now.

* fix: document ->legacy-mbql's conversion-failure fallback

Docstring claimed conversion always succeeds after normalize, but the
try/catch fallback added earlier can still return raw pMBQL when a
stripped query's positional refs don't resolve.

* test: mark edit-chart-result-survives-update-memory-test ^:parallel

Doesn't write shared state, same as the test above it.

* fix: thread edit-chart-tool's resolved query into edit-chart

edit-chart-tool falls back to current-queries-state when a chart's own
:queries is empty, but edit-chart only checked charts-state, so its
result carried :query-id with a nil :query in that case -- the same
[nil]-in-memory bug fixed earlier, via a different path. Pass the
caller's resolved query through as a fallback.

* fix: capture viewing-context charts' query-id, don't clobber on edit

chart-config->chart never set :query_id, so a chart seeded from viewing
context rendered query-id="" on edit and was never captured into :charts
state by extract-charts. Reuse the context-item id already shared with
:chart_id.

That capture also collided with edit-chart-tool's own memory write for
the same chart-id: extract-charts fully replaced the entry, dropping
:image_base_64/:timeline_events/:chart_config the tool had just written.
Merge onto the existing entry instead.

* fix: separate ->legacy-mbql's docstring from its bug history

Docstring for ->legacy-mbql read as the change's rationale (BOT-1604,
the :stages detection story) rather than the function's contract.
Trim the docstring to what a caller needs -- normalizes MBQL 5 to
legacy, passes through unchanged on failure -- and move the "why"
for the :stages guard down to an inline comment next to the check.
2026-08-28 15:10:46 +02:00
Stas Gavrylov
daf31c8bc3 Fix lint-eslint-pure warning noise in fresh worktrees (#81214)
Remove stale /* eslint-env node */ comments from frontend/build files

Flat config ignores eslint-env comments (ESLintEnvWarning per file, hard
errors as of ESLint v10), and they are redundant: eslint.config.mjs already
defines globals.node for frontend/build/**/*.js.
2026-08-28 16:08:27 +03:00
John Swanson
cfd6896c17 respect default time dimensions from list of dimensions (#79167)
* respect default time dimensions from list of dimensions

* Curate a default time dimension on the time-facet test fixture

Master's #79409 stopped auto-assigning a default dimension to new
metrics, so the time-facet tests' fixture card no longer gets a default
time dimension from dimension auto-sync. Since this branch makes the
curated default authoritative for the time-facet variant (no fallback to
the query's temporal breakout), curate the default explicitly on the
fixture.
2026-08-28 05:32:29 -07:00
YB
847666c6d1 [GDGT-2528] return an actionable error when an export target id does not exist (#80809)
return an actionable error when a serialization export target id does not exist
2026-08-28 20:27:13 +08:00
John Swanson
bb2b9786f3 Make register-listeners! idempotent, mirroring the queue-declaration fix (#80887)
The earlier fix (#80388) made *queue declarations* reload-safe by guarding
them with a load-time ownership claim and replace-on-reregister semantics,
but left the *listener* registry non-idempotent: register-listener! threw
"Listener already registered" whenever the channel was already present in
the root *listeners* atom.

Fix mirrors the queue side exactly: def-listener! now claims its channel's
declaration site at load time (claim-listener-declaration! — same-ns
re-claims are no-ops so reloads stay legal, cross-ns claims throw), and
activates through register-declared-listener!, which replaces any existing
registration instead of throwing. Ad-hoc batch-listen! keeps its strict
throw-on-duplicate contract (double-listen-throws-test et al.), since
runtime registrations have no load-time claim protecting them.
2026-08-28 05:01:03 -07:00
Romeo Van Snick
57ac36ff71 Match permissions URLs for databases without schemas (#81332) 2026-08-28 11:25:40 +00:00
Timofey Kachalov
8b42f49288 Normalize instance-origin matching when filtering data-app allowed_hosts (#81106) 2026-08-28 15:14:37 +04:00
YB
3ef8df323a Bump Bouncy Castle to 1.85.2 (#81287)
bump bouncy castle to 1.85.2
2026-08-28 18:44:37 +08:00
Alexander Polyankin
8b478a76ab Encrypt subscription/alert recipient details; enforce allow-list on send (#81227)
* Encrypt subscription/alert recipient details; enforce allow-list on send

Encrypt at rest the `details` column of `notification_recipient` and
`pulse_channel`, which stores external email recipients for alerts and
dashboard subscriptions, so plaintext values are rejected on the strict
read when an encryption key is configured. Register both columns in
`encrypted-string-columns` so `encrypt-db`/`decrypt-db` and key rotation
re-encrypt them, and add a v58 migration that encrypts existing rows and
decrypts them on rollback.

Also enforce `subscription-allowed-domains` at send time (previously only
on write): the notification send path calls `validate-email-handlers!`
before delivery and throws if any external recipient is not permitted by
the allow-list. The exception is re-thrown with a generic message so
recipient addresses are never written to the logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Move the v58 pulse_channel encryption migration test into the v58 group

order-your-migration-tests-test requires migration tests to be grouped by
major version in ascending order; the new test had landed among the v48 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 12:19:26 +02:00
Sébastien
ed6247eb24 Fix collapsed visualization in the dashboard new-question query builder (#81163) 2026-08-28 10:10:07 +02:00
Chris Truter
53a31166db Justify the grandfathered kondo ignores (#78168)
* Justify the :metabase/discourage-millis-duration ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :reduce-without-init ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :def-fn ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unquote-not-syntax-quoted ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :aliased-namespace-symbol ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :equals-true ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :case-symbol-test ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/prefer-with-dynamic-fn-redefs ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :syntax ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unused-binding ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unused-private-var ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unresolved-symbol ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unused-excluded-var ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unresolved-namespace ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unresolved-require ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unused-value ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :unused-alias ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/namespace-name ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :missing-docstring ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/defsetting-namespace ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/validate-deftest ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :missing-protocol-method ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :discouraged-java-method ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :clojure-lsp/unused-public-var ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/modules ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :metabase/test-helpers-use-non-thread-safe-functions ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the bare :clj-kondo/ignore forms

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :discouraged-namespace ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :deprecated-namespace ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :deprecated-var ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Justify the :discouraged-var ignores

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Correct inaccurate justification comments from review

Reword the overclaims the review round caught: 'every render path'
and 'every test-query feature' scoped to what the tests cover, the
'mutates nothing' datasource comments acknowledge lazy pool init, the
'only channel' store comments name the pending general-cached-value
migration, the ASCII-makes-default-locale-safe clauses dropped
(Turkish casing changes ASCII I), and the accidentally-justified
import-vars ignore in metabase.test gets a real comment. Contested
claims move to the uncertain list rather than guessing.

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Use Locale/US casing directly; drop the ignores it obsoletes

The review round was right that the circular-dep comments didn't
justify locale-sensitive casing: a Turkish default locale lowercases
ASCII I to a dotless i, mangling locale codes like "ID" and hiding
"Windows" from the os.name probe. Call .toLowerCase/.toUpperCase
with Locale/US at both sites, which removes the :discouraged-var
ignores entirely instead of excusing them. The logger comment now
states the real invariant: the notice reports logger reconfiguration
itself, so it can't go through log4j.

Claude-Session: https://claude.ai/code/session_01GParLfcfdFBwFrdzUWjGJN

* Correct remaining kondo justification comments

* Correct four justification comments from CodeRabbit review

* Correct the qp.store justification: the mbql5 shim reads the store too

* Complete kondo justification coverage

* Keep the named ignore forms while justifying them

The per-linter sweep was written when these sites still used the bare
`#_:clj-kondo/ignore`, so replaying it over the naming branch put the bare
form back under each new comment. Restore the linter vectors and rebuild
the budgets: :comment-exempt lands at 10, down from the 38 the mechanism
starts with.

* Justify the ignores master added under the exempted linters

The restack picked up new :deprecated-var and :clojure-lsp/unused-public-var
ignores from master. Left unjustified they would have re-exempted both linters,
undoing what this branch is for.

* Regenerate kondo ignore budgets after restacking onto master
2026-08-28 05:59:14 +00:00
Chris Truter
72d6cfe9cd Fail fast when a test loses its rollback boundary (#80994)
Fail the test when a rollback-only scope cannot roll back

Every top-level `with-temp` runs in a rollback-only transaction. If something
inside one commits -- DDL does that implicitly on H2 and MySQL, which also
destroys the savepoint -- the rows written up to that point are durable and
leak into every later test. Warning about it hides the leak until some
unrelated test counts rows and gets the wrong answer, and the six offending
sites found so far were caught exactly that way.

Throw instead when running tests. That covers every cause -- raw SQL DDL,
HoneySQL DDL, a savepoint lost to a deadlock -- where a guard on the SQL we
build would miss the raw statements `drop-table!` sends. Production keeps
warning and discarding what is still pending: there the transaction is a real
one that has already committed, and failing the request would not undo it.
2026-08-28 05:53:46 +00:00
technomancy
f9008f7884 Disable databricks JDBC driver's auto-retry. (#81231)
It sucks!

We will need to put our own error handling in place to do Actually
Good retries for 429s, but it's not clear yet where the best place for
that would be, because we're not getting stack traces from the 429s currently.
2026-08-28 00:05:28 -05:00
Romeo Van Snick
e6e11ee362 Write the metadata mirror from one listener, not 33 endpoint hookups (#81147)
* Write the metadata mirror from one listener, not 33 endpoint hookups

* Describe the hydration listener by what it does, not what it replaced

* Point the original_fields note at the listener, not onQueryStarted

* Pin the hydration listener to Api actions, and report a failed write
2026-08-28 13:16:41 +10:00
Romeo Van Snick
41cd2ab4f7 Name the chunks that lazy routes load (#80970) 2026-08-28 11:46:56 +10:00
Ben Grabow
f73eb6d00e Filter server-side when all target schemas are exactly named (#81250)
* Filter server-side when all target schemas are exactly named

* Blacklist metacharacters instead of whitelist word characters
2026-08-27 17:04:19 -07:00
Romeo Van Snick
21024ec984 Keep MCP node_modules out of the shared vendor chunk (#81157) 2026-08-28 09:40:04 +10:00
Fraser
a7815e9354 Give query_builder a store/ folder and an enforced public API (#79965) 2026-08-28 11:26:07 +12:00
Andrei Chernykh
e819ade161 bump dompurify to 3.4.14; removed from resolutions (#81228) 2026-08-27 15:19:20 -07:00
Nathan Voxland
0163ebfd7a Don't drop column order/visibility settings when one column no longer matches (#76088) 2026-08-27 16:18:30 -05:00
Cam Saul
27852dcc4c Resolve columns from model metadata saved in ~<=55 correctly (#75771)
* Resolve columns from model metadata saved in ~<=55 correctly

* Remove annoying :lib/options in stages and joins

* Test fix 🔧

* Code cleanup

* Comments

* Fix typo

* Revert pointless changes

* Try something different??

* Test fix 🔧

* Will disabling unneeded change fix e2e tests??

* Simplified fix 🔧

* PR Feedback

* cljfmt

* Test fix 🔧
2026-08-27 13:13:48 -07:00
Nathan Voxland
9eb4ce8524 Session Management (#80243) 2026-08-27 14:35:22 -05:00
github-automation-metabase
a9337e6046 Auto-update documentation for master (#81128)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-27 15:33:00 -04:00
John Swanson
96faa6d511 re-check read permission when listing bookmarks (#80774)
SEC-669: re-check read permission when listing bookmarks

GET /api/bookmark (bookmarks-for-user) filtered only on the target's
archived column and never re-checked read permission, so a bookmark kept
leaking a card/dashboard/collection/document/exploration's name,
description, and type after the user lost read access without the item
being archived (moved to a private collection, or a revoked/narrowed
collection grant).

Add a visible_collection_ids CTE (permissions only; archived filtering is
left to the existing where-conditions) and filter each bookmark branch by
collection visibility via visible-collection-filter-clause. Visibility is
evaluated for the passed user-id via a UserScope, so the function is
correct for any user, not only the current request's user.
2026-08-27 12:28:20 -07:00
Uladzimir Havenchyk
dff2fd94ce chore: replace lodash.orderby with inline function (#81160)
* chore: replace lodash.orderby with inline function

* drop deps

* fix eslint
2026-08-27 22:15:20 +03:00
Nathan Voxland
a25cc1f7be Improve oracle parsing (#80043) 2026-08-27 14:12:55 -05:00
Nathan Voxland
5c63c45970 Better error handling on result_metadata.settings (#80909) 2026-08-27 14:12:10 -05:00
Andrei Chernykh
cb3d067c6e Bump js-yaml to 4.3.1 (#81229) 2026-08-27 12:10:32 -07:00
Andrei Chernykh
3ac0958c50 Replace @sinonjs/fake-timers with a local Date mock (#80930) 2026-08-27 11:46:36 -07:00
Egor Grushin
05300912fb Radio component redesign (#80372)
* Redesign Radio

* Add Radio stories to loki filter

* disallow radio labelPosition

* use StoryBoard in radio stories

* add new story's snapshots

* Radio.Card: border, leftSection, migrate call sites

- card border 0.5px border-neutral-strong, none when disabled
- children disallowed, leftSection slot for icons
- EditUserStrategyModal + DataSegregationStrategyPicker on label/description

* update loki snapshots

* Icon color prop instead of css module in DataSegregationStrategyPicker

* remove extra div

* make the indicator in the radio card optional

* fixed radio size, no size prop

* radio card body via mantine text/stack

* forwardRef radio card

* radio card border: highlight when selected

* update loki snapshots

* drop size="xs" from radio call sites

* fix card growing on select, interactive card stories, optional checkbox card indicator

* skip interactive card stories in loki, update card snapshots
2026-08-27 19:35:48 +01:00
Riley Thompson
3ecdab5ea7 Test for mongo multi column aggregation on joined models (#81079)
* add test

* use test helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bfeq38ZBHxqfwJAgRaRH4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 18:34:19 +00:00
Ben Grabow
d18a2df374 Fix flaky snowflake key sharing (#81197) 2026-08-27 17:33:41 +00:00
Chris Vandoodewaard
32ff8a7c7c paginate PR results for backport Slack reminder (#81216)
paginate PR results
2026-08-27 13:16:27 -04:00
Chris Truter
0be2972872 Make rollback-only app DB transactions actually roll back (#80065)
* Key cached session tokens on the app db

with-empty-h2-app-db! swaps in a different application database, and this cache was keyed on the username
alone. A token minted against the previous database names a session that does not exist in the new one, so
the request 401s, and the token minted inside leaks back out when the original is restored. The retry in
client-fn cleared the whole cache and tried again, which papered over it often enough to look like flake:
"Failed to authenticate X after three tries".

Key it on the app db as well, like every neighbouring cache does via memoize-for-application-db.

databases-list-can-upload-test and dismiss-spinner-test go from failing to passing; both were in the CI
failure list, the former across 12 jobs.

* Fix rollback-only app DB transaction semantics

* Reject rollback-only combinations that cannot roll back

- :nested-transaction-rule :ignore skips the savepoint :rollback-only needs, so the pair is rejected at any
  depth rather than silently committing
- a throw out of the transaction body now rolls the connection back explicitly, since restoring autocommit
  commits whatever is still open -- a requested rollback that failed was committing the work it had to discard
- the unsupported-options error names the options, and drops the "application database" framing that
  misattributed failures on other java.sql.Connections

* Restore transaction state even when the savepoint rollback throws

The callback accumulators are discarded unconditionally at the top of rollback!, so leaving the state
restore behind a throwing .rollback left the tree half-unwound: an outer scope that caught the error and
committed would run its before-commit callbacks against the failed nested scope's state.

* Create test-data Databases outside the caller's transaction

Their ids are memoized for the whole app db, so the Database has to outlive whatever scope first asked for
one. Created inside a test's rollback-only with-temp it is rolled back while the memo keeps its id, and every
later lookup hands out an id for a row that never existed.

do-outside-transaction is the same detachment run-after-commit-callbacks! already did privately.

* Don't cache a personal collection id created inside a transaction

The cache's premise is that a Personal Collection cannot be deleted, so its id never changes. A rollback
breaks that: the collection goes away and the cached id points at a row that never existed, which reads as
a 403 on every later request that resolves the user's personal collection.

* Skip the test-database cache in a transaction rather than detaching

Detaching made the Database durable but took a second connection while the caller's transaction was still
open. When that transaction holds cluster lock rows -- data_permissions and search index setup both take
them -- the second connection waits on them until the lock acquisition times out.

Bypassing the cache keeps the work on the caller's connection, and matches how the personal collection id
cache handles the same problem. do-outside-transaction goes with it, having no other callers.

* Stop the outer commit when a requested rollback failed

The full rollback only runs when this call turned autocommit off, so for a nested scope or a caller-managed
connection a failed rollback! just propagated. Code between there and the outermost scope can catch it, and
the commit branch would then commit the writes the caller asked to discard.

Mark the tree instead, and refuse to commit it. Throwing rather than quietly rolling back: the caller asked
to commit and cannot, and dropping the outer work silently trades one bug for a worse one.

* Tolerate a savepoint an outermost rollback-only can no longer roll back to

A statement in the body can commit the transaction under us -- DDL does that implicitly on H2 and MySQL --
which invalidates the savepoint. Those writes are durable by then, so failing the scope reports a problem
nobody can act on while leaving the data exactly where it was.

At the outermost scope, discard whatever is still pending and log where it happened. Nested scopes still
poison the tree and throw, since there the enclosing work is pending and must not be committed.

* Note the option of rejecting DDL inside a savepoint-bearing transaction

* Never commit a transaction whose rollback failed

Four review passes flagged the same hole from different angles. Restoring autocommit commits whatever is
still pending, and after a rollback we were relying on to discard those writes, that is exactly the work
we were told to throw away. Track it and leave autocommit off instead, letting the pool roll the
connection back on check-in.

Two narrower routes to the same place:

- rollback! now marks the tree itself, so an exception-driven rollback that fails poisons it too. Before,
  only a requested rollback did, and the error path could still reach a commit.
- the mark is rechecked after before-commit callbacks drain. A callback can catch a nested scope whose
  rollback failed, which lands after the earlier check has already passed.

Two tests changed meaning as a result: a nested scope whose rollback throws now stops the outer scope
committing, so it no longer completes and its own after-commit callbacks no longer run. That is the point
-- its writes are still there.

Also adds the regression test for personal collection id caching, models autocommit honestly in the mock
rather than pinning it true, and records what the in-transaction test-Database lookup gives up.

* Build the test-data Database before with-temp opens its transaction

Created inside a rollback-only scope it never survives, so it is rebuilt for every test, and the ids
memoized in metabase.test.data.impl outlive it. Cards then get inserted against table ids for rows that no
longer exist: FK_REPORT_CARD_REF_TABLE_ID on a fresh app db.

Materialise it before the transaction opens, where it can commit. The table and field lookup maps get the
same in-transaction bypass the Database id already had, for the case where a dataset is first asked for
inside a scope anyway -- neither is enough on its own.

Reproduce with a throwaway app db, which is what CI has and a local one does not:

  MB_DB_TYPE=h2 MB_DB_FILE=/tmp/fresh-$$ ./bin/test-agent :only '[metabase.queries.models.card-test]'

* Only pre-warm the dataset for the default driver

db-id resolves through the driver under test, so in a driver-test namespace the pre-warm reached for that
warehouse's dataset -- work a with-temp there may never need, against a warehouse that may not be up.
Driver failures went from 2 to 12 in the push that added it.

The FK problem it fixes is on the app-db side, for the default dataset, so confine it there. Best effort
even then: a dataset that cannot be built must not take an unrelated with-temp down with it.

* Evict temporary users from personal collection cache

* Evict the personal collection cache on the rollback, not on the user

Clearing the temp User's own entry misses the case that actually bites: a committed user's Personal
Collection is created lazily on its first permission check, which often lands inside somebody else's
with-temp. Nothing evicts it when that scope rolls back, and every later request for that user 403s.

Key off the with-temp boundary instead, and drop whatever it may have cached. Costs a re-lookup per user
afterwards; correct whichever user and whichever scope created the entry.

agent-api + activity-feed went from 21 failures to none.

* Skip the dataset pre-warm where the app db is deliberately empty

with-empty-h2-app-db! rebinds the app db, and the memo key includes its identifier, so the pre-warm missed
the cache and created a test-data Database inside a scope whose whole contract is emptiness.
databases-list-can-upload-test snapshots an empty Database set and saw two.

Bind *skip-dataset-prewarm?* there instead. Also drops the earlier :h2 restriction, which came from a bad
reading of two CI runs sampled at different completion stages: it could never have helped here, since those
helpers are H2, and it took the pre-warm away from driver tests that needed it.

Reproduce with a throwaway app db, which is what CI has and a local one does not:

  MB_DB_TYPE=h2 ./bin/test-agent :only '[metabase.queries.models.card-test]'

* Register the cache eviction with a unique key

metabase.test.redefs already holds `:around :default` on do-with-temp*, and methodical overwrites methods
sharing a key rather than composing them. Whichever namespace loaded last would win, silently dropping
either this eviction or the rollback-only transaction wrapper. It happened to work; it should not depend on
load order.

Also notes what a failed connection-level discard leaves behind. Only a caller-supplied connection is
exposed: on one we own, the same flag stops autocommit being restored, so the writes are never committed.
It logs rather than throws because throwing from that catch would mask the exception being unwound, and
master never attempted the discard at all, so a caller-supplied connection is no worse off than before.
Signalling it properly would mean telling the caller some other way.

* Note that session tokens are cached without regard for transactions

The user ids directly above skip the memo inside a transaction, since users created in a `with-temp` are
discarded with it. The token cache has no equivalent guard, so a session rolled back with its scope leaves a
token behind and the request fails with "Failed to authenticate X after three tries". The retry in
`client-fn` hides it more often than not.

* Note that a rollback can leave a stale personal collection id cached

"A Personal Collection cannot be deleted" does not hold for a transaction that rolls back. Tests evict on
the with-temp boundary; production has no equivalent and keeps the id for the rest of the TTL. Accepting
that for now -- it predates honoring :rollback-only, and closing it properly wants an after-rollback hook
alongside do-before-commit/do-after-commit.

* Follow the created-globally rename in the token cache TODO

* Build the query for export inside the guard, not beside it

`:query_json` already returns nil for a card whose query cannot be exported, but `lib/query` was called in the
argument position, outside `try-export-query`. A stored `dataset_query` with no stages builds a query that fails
its own output schema, so one such Card takes down every response that walks a set of Cards -- the typed-schemas
endpoint among them.

metabase.search.appdb.scoring-test alongside metabase.typed-schemas-rest.api-test goes from 13 failures to 0.

* Don't run a test request inside a rollback-only transaction

`user-request` takes a temporary Session when it is handed a User or an id rather than a `:rasta`-style keyword,
and the handler runs in-process on this thread. Now that the with-temp rolls back for real, every app db write
the request made is discarded when it returns -- the warehouse table writes survive on their own connection and
the API still answers 200, so the test sees a successful call that changed nothing.

Insert the Session and delete it in a `finally` instead. A caller already inside a transaction still gets the
same behaviour it always had; what goes away is the extra rollback scope around the request itself.

metabase-enterprise.action-v2.models.undo-test goes from 25 failures to 0, passing all 134 assertions, which
matches master.

* Mint the test users' sessions before anything opens a transaction

`authenticate!` ran about 150 times in a single `pulse.api.pulse-test` run, nearly always inside a transaction.
A session minted inside a `with-temp` is rolled back with the scope, the next request 401s, and the retry in
`client-fn` cleared the whole cache -- every user's token, not just the dead one -- so everyone re-authenticated
in-transaction and the cycle repeated. Each of those inserts bumps the user's `last_login` through the
`:model/Session` after-insert hook, which is what breaks a test that reads the creator before making its request.

Mint the four test users' sessions at initialization, where nothing has opened a transaction yet, so they are
durable. On a 401 forget only that user's token, so one temporary user's rolled-back session cannot evict them.

pulse.api.pulse-test goes from one failure to none, matching master. publish-refresh-error-event-sends-admin-email-test
passes too, where it fails standalone on master: that email only goes to admins with a `last_login`, and
crowberto's was rolled back with every login.

* Ask for the FieldValues the chain filter test needs

The full FieldValues row for `categories.name` is created lazily, and `update-field-values-for-table!` only
refreshes rows that already exist -- so a run that creates it inside a `with-temp` discards it and nothing
makes another. `select-one-pk` then returns nil and the test dies on "Not something with an ID".

Same shape as the rest of this branch: a durable row created on demand, now rolled back with the scope that
created it. Ask for the row instead of assuming an earlier test left one behind.

* Run the metric leaf queries one at a time inside a transaction

A transaction owns a single connection and `future` hands that binding to every thread it starts, so the
leaves' app db writes -- used-card stats behind a cluster lock, query rows -- interleave on one session.
Savepoints are per session: a thread rolling back to its own destroys the ones its siblings are holding, the
next release fails with "savepoint does not exist", and the transaction aborts, taking the rest of the test
with it. That is why api-arithmetic-test loses a different test on each CI run.

Only tests get here. A request runs in no transaction, where each future takes its own pooled connection and
the parallelism is worth having.

On a throwaway Postgres the namespace errors on two tests per run before this and passes three runs out of
three after.

* Commit a lock row that would otherwise roll back with the caller

A lock row is written once and read forever after, and acquisition leans on that: rival first-time inserters
block on the winner's uncommitted unique-index entry and retry once it commits. Inside a `with-temp` that
commit never comes, so the row is gone again and the next acquisition replays the race -- on MariaDB until the
lock-wait timeout expires.

Insert it on a connection of our own when the caller was already in a transaction, which is the only case
where the row would not survive. Nothing in production takes a cluster lock from inside another transaction,
so this leaves that path alone -- worth keeping that way, since lock names are dynamic in places and rows that
used to roll back with a failed transaction would otherwise persist.

Reasoning rather than measurement; the TODO on the function says so and says what to do if it does not help.

* Bound the out-of-band insert on its own connection

`set-query-timeout!` returns false on MySQL running MariaDB Connector/J, and the fallback sets
`innodb_lock_wait_timeout` on the session -- but `acquire-lock-row!` sets it on the caller's connection, not
the one the insert opens. Blocked by a rival's uncommitted row, the insert would then wait out the server
default instead of the second it was given, on exactly the databases this is meant to help.

Give it the same treatment on its own connection. Also note in the docstring that the second connection is
only taken until the row exists, which is what bounds the cost of holding two at once.

* Resolve the venues id before counting queries

The cache is fine; the query the test counts is its own `(mt/id :venues)`. Inside a transaction the test-data
id lookup deliberately skips its memo, so the call lands in the window that is supposed to see none.

* Keep the pre-warm out of app dbs that are meant to be empty

`with-empty-h2-app-db!` already opts out of the dataset pre-warm; the serialization test dbs and the
schema-migration ones never did. A `with-temp` inside them materialised the test-data Database, and an extract
then saw a Database no assertion expects -- serialization.v2.load-test/database-test wanted one and found two.

* Do not insert a lock row out of band outside tests

The claim in "Commit a lock row that would otherwise roll back with the caller" was wrong: production does take
cluster locks inside another transaction. `load-from-h2!` inserts Databases, and so takes the batch-permissions
locks, inside one big load transaction. Our fresh connection then blocked on a next-key gap lock the caller's
own SELECT ... FOR UPDATE was holding, and a thread cannot outwait itself -- it timed out, retried, and failed.
That is what broke dump-to-h2, load-from-h2 and rotate-encryption-key on MariaDB.

Gate the out-of-band insert on `config/is-test?`, which is the only place a transaction may never commit, so
production goes back to exactly what it did before. Keep a fallback for the same shape inside tests, where
`load-from-h2!` can block on itself just as readily: on a lock-wait timeout, insert on the caller's connection.

* Stop three card tests leaving rows behind

`updating-model-query-does-not-shift-metadata-overrides-test` had no cleanup at all, and
`create-card-with-type-and-dataset-test` opened a plain `t2/with-transaction` where its siblings pass
`{:rollback-only true}` -- so both committed cards with a `{}` dataset_query that every later test walking the
card table then had to survive. That leak is older than this branch; honoring the rollback is what makes the
second fix work.

`get-series-for-card-permission-test` read crowberto's personal collection with `select-one-pk`, and a nil
collection_id puts the card in the root collection, which rasta can read -- so the test quietly asserted 403
against a card that was never private. Ask for the collection instead.

* Truncate the heartbeat fixture times to milliseconds

Postgres stores microseconds, so a timestamp ending .7369995 comes back a millisecond above its own
millis-truncation and the assertion misses by one.

* Tolerate a card whose query will not parse when finding related entities

Nothing at the schema level stops a stored `dataset_query` being empty, and such a card simply has no
context-bearing forms. Without the guard one of them takes down the related-entities computation, and with it
every x-ray, for everyone else. Same treatment `card-details` already gives an unexportable query.

* Give the end-to-end schema test a Database of its own

The test asserts exactly which questions, metrics and models fall inside the test-data Database's scope, which
holds only as long as no other namespace commits a Card against that Database. Two such leaks have been fixed
on this branch already and there is at least one more; the assertion cannot be made safe by chasing them,
because any test added later can break it again.

Run it against a copy of the Database instead, so nothing else can write there.

A throwaway namespace that commits one Card on the test-data Database ahead of it reproduces the CI failure
exactly (core_test.clj:147) and passes with this change.

* Recognise the MySQL lock-wait timeout in the in-band fallback

`lock-wait-timed-out?` knew about `SQLTimeoutException` and our own `::acquisition-timeout` marker, but not
ER_LOCK_WAIT_TIMEOUT itself. The server raises that on its own schedule, so on MySQL and MariaDB -- the
databases the fallback exists for -- it could arrive as a plain `SQLException` and the fallback would not fire.
Check the code through the cause chain.

* Take the revisions with the cards

`with-model-cleanup` deletes rows directly, which skips the Card hook that would have removed the revisions
this test's POST and two PUTs create. Name Revision in the cleanup as well.

* Truncate once, and align the chain filter's temp values

The second `truncate-to` could not do anything the first had not.

* Create the test users before the related tests race for them

`user->id` skips its memo inside a transaction and creates the users there instead, so on this branch they roll
back with the scope and the next test makes them again. These tests are parallel and every one of them takes a
`with-temp` Card whose creator defaults to rasta, so several threads insert the same user at once and all but
one time out on the row lock. The namespace goes from three errors to none.

Also covers the guard in `related/definition`: a Card whose stored query will not parse contributes no
context-bearing forms, and a genuinely similar Card still outranks it.

* Cover the card whose query will not build

The guard in `card-details` had no test: a Card with a stage-less stored query answers without `:query_json`,
and the Card beside it in the same response is unaffected.

* Cover the batch path and the lock-wait code

The first version of the card test called `get-table-details` once per card, which cannot see the failure it
claims to cover -- the bug was that one bad card killed the whole `cards-details` seq the others were in. Go
through that seq instead. Removing the guard turns the test red at the same malli output error CI was showing,
which the per-card version did not.

Also cover `lock-wait-timed-out?`: MySQL's ER_LOCK_WAIT_TIMEOUT on its own and inside a cause chain, and any
other error still propagating rather than being taken for a timeout.

* Create the temp search index before the transaction, not inside it

`with-temp-index-table` runs CREATE TABLE and DROP TABLE on the ambient connection, and on H2 and MySQL that
commits the transaction around it. Six tests opened it inside a `with-temp`, so the rollback had nothing left
to take back and every row they created leaked -- a Transform, some Cards and Collections, a published Table, a
Document -- to any later test that walks the app db.

That one leaked Transform is the whole of the serialization failures: a collection-scoped export includes every
Transform in the instance, so all four tests came back with one entity more than they expected.

Take the index table before the `with-temp` instead. The nested scope reuses it, so nothing else moves.

`metabase-enterprise.search.scoring-test` also had no fixtures at all, and fails on its own: the first lazy
`initialize :db` fires mid-test and recreates the H2 database underneath a test already writing index
bookkeeping.

The 133 namespaces that run before serialization.api-test in the Enterprise job reproduce the CI failures
exactly -- 5011 assertions, 5 failures -- and pass with this, at 4714 assertions.

* Say the cluster lock comments plainly

Lead with the point rather than building up to it, and drop the clause chains. No behaviour change.

The TODO above `clear-cached-session-tokens!` was stale: it asked for the `created-globally` treatment, which
landed earlier on this branch. Point at what is actually left instead -- initialization can still first happen
inside a `with-temp`, and then the sessions roll back with it.

* Keep the non-index assertions where the app db cannot index

`with-temp-index-table` is a `when`, so wrapping a whole test in it skips the whole test on MySQL and MariaDB.
Two of the tests moved in the last commit also assert things that need no index, and those assertions went
quiet there. Add a sibling that runs the body either way, and use it for those two.

`search.appdb.scoring-test` had no initialization fixture, the same gap its enterprise counterpart had: the
first lazy `initialize` can recreate the H2 db underneath a test already writing index bookkeeping.

* Sort the fixtures require

* Download through the client that retries a 401

`downloaded-card-filenames-test` passed a raw cached token straight to `client-full-response`, so it skipped
the retry every other test gets from `client-fn`. A session rolled back with the scope that made it then came
back here as a bare 401, which is what the BigQuery job has been failing on.

`user-http-request-full-response` still returns the headers the test reads.

* Improve rollback test isolation and comments

* Do not poison the transaction tree when the savepoint is already gone

MySQL and MariaDB roll back the whole transaction to break a deadlock, discarding every savepoint with it. The
ROLLBACK TO SAVEPOINT that follows fails with "SAVEPOINT does not exist", and marking the tree rollback-required
there is backwards: the server has already discarded the writes, so nothing is pending for an enclosing scope to
commit by mistake. A rollback-only scope whose DDL committed implicitly already gets this treatment.

The refusal message named a rollback-only transaction, but the ordinary error path sets the flag too.

Also validate :nested-transaction-rule with a throw. It is a public option, and `assert` compiles away under
`*assert*` false. Sorting the unsupported keys keeps the message stable across runs.

* Keep the test request's session out of the login hooks

:model/Session's after-insert hook publishes :event/user-login, which stamps User.last_login, and
:event/user-joined for a User that has never logged in. Deleting the Session afterwards undoes neither, so every
user-http-request left both behind for whatever ran next.

Write the row instead. created_at has to come along, since the model supplied it.

* Ask whether the app db can hold the index, not whether any engine can

supports-index? is true for any active engine. With semantic search on MySQL or MariaDB, the macro that exists to
run the body without an app-db index took the app-db branch and tried to create the table anyway.

* Say something when a stored query is dropped

Both guards swallowed every exception silently, so a Card whose query failed to build for an unrelated reason
looked exactly like one that has no query. try-export-query, right next to the Metabot guard, already warns.

Related entities logs at debug: it runs for every candidate Card while ranking.

* Commit the out-of-band lock row rather than assume autocommit

The docstring said the row was committed, but nothing committed it -- the insert relied on the pooled connection
arriving in autocommit, which the pool does not promise. The caller's SELECT cannot find the row otherwise.

* Let a failed dataset pre-warm be heard

Now that the pre-warm runs for every driver, a warehouse that is down fails it on every top-level with-temp. At
debug that is invisible on a normal run and resurfaces much later as a Database that does not exist. Warn once
per message.

Also note that mt/id costs a query inside a transaction, which is what a with-call-count window has to plan for.

* Polish prewarm comments

* Mark the tree rollback-required even when the savepoint vanished

A vanished savepoint does not mean nothing is pending. DDL committing implicitly, or a server breaking a deadlock,
ends the transaction and discards the writes so far -- but anything written afterwards sits in a fresh transaction
and is still pending. Skipping the flag there let an enclosing scope swallow the rollback error and commit exactly
those writes.

Say so at the point of the catch, since the reasoning does not survive a quick read.

* Gate every app-db index helper on app-db support

with-temp-index-table and with-appdb-search-if-available-otherwise-legacy still asked supports-index?, which is
true for any active engine. With semantic search on MySQL or MariaDB they ran app-db DDL the app DB cannot take,
including from scopes nested inside the fallback body that exists to avoid exactly that.

Cover the borrowed session too: it must leave last_login empty, and delete itself afterwards.

* Cover the out-of-band lock row against a connection in a transaction

Hands the dedicated connection over with autocommit off and checks the row survives the caller's rollback. Without
the explicit commit the insert dies with the connection, and acquisition then fails on the row it just wrote.

Runs on MySQL and PostgreSQL only; H2 takes an in-process lock and writes no row.

* Pin the vanished-savepoint case against the outer commit

A savepoint the server already discarded says nothing about what was written after it. The existing rollback tests
throw generic exceptions, so nothing stopped the SQLState 3B001 / MySQL 1305 exemption from coming back.

* Pin the engine in the otherwise-legacy fallback

Where the app DB cannot hold an index, the fallback took whatever default engine was active. Semantic can be
active there, and the bodies are written for an index the app DB does not have. Run them under legacy search, as
the macro's name says. with-legacy-search moves above its first use.

* Polish rollback and search comments

* Stop two remapping scopes deadlocking on MySQL and MariaDB

A remapping writes a Dimension for a Field the whole suite shares, and holds it until its scope ends. Two threads
doing that at once deadlock on the unique index over dimension.field_id: each holds the record lock the other's
uniqueness check wants, while asking for the insert-intention gap in front of it. MySQL and MariaDB pick a victim
and roll it back, which drops its savepoints, so the rollback that follows fails on a savepoint that is gone.

Serialise remapping scopes against each other. Tests that never remap keep running in parallel alongside them.

The helper already carried a TODO saying it was not thread-safe.

Reproduced on MariaDB 12.3 and confirmed on master, so this predates the branch. custom-values-test failed nearly
every run before, and three runs in a row are clean after. Verified on H2 as well.

* Serialise every temporary Dimension, not just remapping scopes

Remapping is not the only way a test writes a Dimension for a shared Field: card-test, card/metadata-test,
field-values-test and data-studio's table-test all create them directly, in parallel, on the Orders and Products
fields. Those still raced with the remapping scopes.

Move the lock to `do-with-temp*` for :model/Dimension so every site is covered, including ones added later.
`locking` is reentrant, so a `with-temp` holding several Dimensions still works.

* Take the search index out of six more with-temp transactions

Same fix as the earlier pass over the search tests, for the ones that pass a warehouse driver: their index scope
sits inside `with-temp`, so `drop-table!` runs DDL on the ambient connection. H2 and MySQL commit the transaction
to do it, which makes the temporary Transforms durable and destroys the savepoint. The rollback that follows can
no longer take them back, and it discards toucan's compensating deletes along with everything else still pending,
so the Transforms outlive the test.

Three tests later in the same namespace count transforms and saw the strays: root-collection-items-include-
transforms-test expected one and found four.

These were missed the first time because `mt/normal-drivers-with-feature :transforms/table` skips them on H2, so
only a driver run reaches them.

Reproduced with a MariaDB warehouse: transform-test went 3 failures to 0, and the four namespaces that failed
together now match master at 2532 assertions. H2 is unchanged at 109.

* Let the failed-rollback warning reach the console

The shared Console appender only accepts FATAL, so the warning naming a rollback-only scope that could not roll
back went to logs/test-log.json and nowhere a CI run gets read. It took hours to find six tests leaking rows
because of it; the warning had been firing the whole time.

Give that logger an appender that accepts WARN. The message carries the mb-test key, so it names the test that
broke its own isolation rather than the later one that trips over the rows.

* Hoist the search index scope above with-temp in two document search tests

Both tests opened `with-appdb-search-and-legacy-search` inside `mt/with-temp`.
That scope creates and drops the index table on the ambient connection, and on H2
and MySQL the DDL commits the transaction `with-temp` meant to roll back, so the
temp Documents leaked into later tests.

Found by the throw this branch adds: both showed up as errors at connection.clj:293
in the app DB test jobs.

* Serialize the reduced-fields card test

It holds a rollback-only savepoint across two `with-temp` Cards and an API call. Run in parallel, something else
commits the transaction that savepoint lives in, so both Cards are discarded and the downstream query reports its
source Card as missing.

Only MySQL surfaces it, and only under a full suite: the test passes on its own, and six local configurations could
not reproduce it. Confirmed by running the MySQL and MariaDB driver jobs with this single change -- all four green,
against four runs where they failed.

* Materialize the test users' Personal Collections before with-temp opens

`user->personal-collection` is a get-or-create and sits on the permissions path every API request takes. A
namespace that does not initialize `:test-users-personal-collections` creates Lucky's the first time a request
calculates permissions -- which happens inside a `with-temp` -- so the transaction rolls it back. Every later
request tries to create it again, and the parallel ones time out on each other's COLLECTION table lock:

  GET /api/database expected a status code of 200, got 500.
  Timeout trying to lock table; INSERT INTO COLLECTION (..., "Lucky Pigeon's Personal Collection", ...)

That is what failed `databases-list-include-saved-questions-test` on the Mongo, Redshift and Athena jobs -- the
empty database list was the 500, not a visibility problem.

Prewarm them next to the dataset, under the same skip flag, so helpers that need an empty app DB are unaffected.
This also closes the related hole where `:test-users` itself first initializes inside `with-temp`, since that
component initializes it.

Confirmed on the Mongo driver jobs: MongoDB Latest went from failing to green.

* Update the test-user session note now that initialization is prewarmed
2026-08-27 19:05:06 +02:00
bryan
13dde0bba3 Reject unneeded connection properties in SQL-JDBC additional-options (#81097) 2026-08-27 10:29:18 -06:00
Nathan Voxland
c24f084ae6 Improve SVG handling (#80249) 2026-08-27 11:25:08 -05:00
Andrei Chernykh
e1fbadec0b Remove unused raf dependency (#80931) 2026-08-27 09:17:21 -07:00
Andrei Chernykh
ad48fed046 Remove the direct reselect usage and dependency (#81034) 2026-08-27 09:16:17 -07:00
Andrei Chernykh
7c08b5d95c Remove cypress-each (#81086) 2026-08-27 09:15:55 -07:00
Andrei Chernykh
25ed6aac2b Swap merge-refs for Mantine's mergeRefs (#81104) 2026-08-27 09:15:15 -07:00
Andrei Chernykh
5c169f142a Replace react-innertext with a local helper (#81105) 2026-08-27 09:14:44 -07:00
Chris Truter
42cf92479a Ratchet config-level kondo suppressions too (#78217)
* Ratchet config-level kondo suppressions too

:config-counts in ratchets.edn tracks, per linter, the :off switches and
:exclude entries in .clj-kondo/config.edn (top-level and per ns-group).
Only weakening counts: adding discouragements or turning a linter on is
free. Lowering works like the inline budgets; raising is a hand edit.

* Count every excluded item and :config-in-comment in the config ratchet

Map-shaped :exclude values with vector entries counted as one suppression
({compojure.core [GET DELETE POST PUT]} is 4, not 1), and the
:config-in-comment :off switch wasn't aggregated at all, so both could
grow without tripping CI. Reseeded.

* Count :config-in-call suppressions, and self-heal on config.edn

`:config-in-call` is a scoped `:linters` map like `:config-in-ns`, so a
linter switched off inside one was invisible to the ratchet. Both scopes
now fold into the same count.

The self-heal workflow only watched Clojure sources, which was right while
budgets came from inline ignores alone. Now that `.clj-kondo/config.edn`
feeds `:config-counts`, removing a suppression there can leave a budget
stale with no way to trigger the fix.

* Count scoped :off suppressions at any nesting depth

Linter scopes nest more than one level: :discouraged-java-method keys by
class and then by method. Counting only the first level meant an :off one
map further down waived a warning the ratchet never saw. No budget moves --
the repo's only two-deep entry is a :level :error discouragement.

* Regenerate kondo ignore budgets after restacking onto master
2026-08-27 17:40:39 +02:00
Kevin Kelleher
fc487f06c9 Explorations summary document (#78848)
Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-27 11:32:54 -04:00
github-automation-metabase
6d2c060ef1 Fix flaky document-comment split test (#77391)
* Fix flaky document-comment split test by using realClick on hover-revealed button

The test clicked the hover-revealed "new thread" comment button of the
second paragraph via Cypress's synthetic .click(). After realHover() moved
the cursor to reveal the button, the synthetic click could act on the button
while its hover transition was unwinding, so the click did not reliably
trigger the route navigation that opens the comments sidebar — leaving
getSidebar() to time out.

Switch to .realClick(), which moves the real mouse onto the button (keeping
it visible/stable) and dispatches a real click that reliably fires the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Wait for document save to persist before opening comments sidebar

The split-paragraph test opened the comments sidebar immediately after the
Save button disappeared, but a successful save schedules a deferred
navigation back to /document/:id. Under network throttling that navigation
resolved after the sidebar route had opened, clobbering it, so the sidebar
never mounted and getSidebar timed out. Wait on the PUT request and the
"Document saved" toast so the post-save navigation settles first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-27 17:28:43 +02:00
Nicola Mometto
f8ce01fcca Check the email_verified claim in the generic OIDC provider (#80269)
Google SSO already requires the id-token email to be verified; the
generic OIDC provider (and slack-connect, which derives from it) did
not look at the claim at all.

Tokens with email_verified present and not true are now rejected. An
absent claim is still accepted: it is optional per OIDC Core and some
IdPs never send it.
2026-08-27 16:16:12 +01:00
Merlin-S3NS
e433af3f90 Fix: Support Google Cloud Universe Domain in BigQuery driver (#68607)
* Fix: Support Google Cloud Universe Domain in BigQuery driver

* feat(bigquery): Add universe domain to connection properties UI

* feat(bigquery): improve universe domain helper text

* Update bigquery_cloud_sdk.clj

Indeed we are out of sync as the impersonating was removed from master branch.

* removed empty comment

* Fixing formatting
2026-08-27 10:57:23 -04:00
Nicola Mometto
57f6f37864 Match SSO group names by exact string value when syncing memberships (#80615)
Ignore non-string SSO group names when syncing group memberships
2026-08-27 15:45:51 +01:00
github-automation-metabase
2ef0e4d264 Fix flaky dashboard bulk-move empty-state test (#78457)
Wait for the question move PUT to settle before navigating to the
affected dashboard, so its empty state is asserted only after the
dashcard has actually been removed.
2026-08-27 16:44:22 +02:00
Nemanja Glumac
c090eb292a Delete redundant e2e spec (#81174) 2026-08-27 16:03:30 +02:00
Kevin Kelleher
9e329ce6fe Ensure highlighted bars are included in brush drill filter (#80025)
* ensure highlighted bars are included in brush drill filter

* fix e2e

* fix e2e again, December 2026 label was not appearing maybe due to a screen size flake
2026-08-27 13:53:32 +00:00
github-automation-metabase
0d36304bf5 Stabilize scatter tooltip test against ExplicitSize resize flake (#78458)
The 'adding non-series columns to the tooltip' scatter test hovered a
bubble immediately after render. The ExplicitSize throttle re-renders the
chart once its container is measured, moving bubbles out from under the
realHover() before the tooltip fires, so [data-testid=echarts-tooltip]
was intermittently never found (4000ms timeout).

Apply the same view-toggle stabilization the sibling tooltip test already
uses (extracted as stabilizeScatterChart) before each hover, forcing a
re-render against an already-measured container.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-27 15:29:26 +02:00
Chris Truter
dd1cb0f0ba Require justifications for linter ignores (#77820)
* Require a justification comment above inline ignores, ratcheted per linter

An ignore of a linter outside :comment-exempt needs a substantive ;;
comment on the line above or trailing on the same line (bare ;; dividers
don't count, and no attempt is made to check the comment is about the
ignore). ~18% of ignores have one today, so 35 linters start exempt.
The set only shrinks: once a linter's last uncommented ignore gains a
comment, the fixer drops its exemption and CI holds the line.

* Locate trailing justification comments in masked source

A semicolon inside a string or character literal on the ignore's line
counted as a trailing comment, so an unjustified ignore could pass.
The masker now keeps the ; that starts a comment (and blanks the char
after a backslash), so justified? finds only real comment starts.

* Share one tree scan across the ratchet tests

Each ratchet test walked and slurped the whole source tree; a delay
runs the scan once for all three.

* Invalidate the shared tree scan per test run

The namespace-level delay cached the scan for the JVM's lifetime, so a
long-lived REPL compared fresh ratchet-file contents with stale counts.
The :once fixture now resets the cache; a directly-run test var scans
fresh.

* Scope the shared tree scan to the fixture's lifetime

The atom cache survived a run, so a later directly-invoked test var
(which skips :once fixtures) reused a stale scan. The cache now holds a
delay only while the fixture is live; without it every call scans fresh.

* Require canonical syntax for kondo ignores

Keep ignore detection deliberately lexical and fail closed: every real marker must use the key-first map form, otherwise the ratchet rejects it with an actionable error. This avoids maintaining a partial Clojure reader for the two attr maps that previously placed the ignore behind another key.

Justification comments must be substantive and either directly above the ignore or trailing on its line. Authenticate comment openers in masked source so strings and code-line comments cannot pose as justifications.

* Harden kondo ignore scanning edge cases

* Use reader delimiters for kondo marker boundaries

* Match Clojure reader whitespace in kondo markers

* Reserve clj-kondo namespaced map syntax

* Harden kondo scanner diagnostics

* Rebuild the budgets and exemptions on the named-linter base

Rebased onto the ignore-naming branch, so every budget is recomputed from
the tree and :comment-exempt is reseeded with exactly the linters that
still have an uncommented ignore: 38, up from 35. The three extra are
linters that were hidden inside bare `#_:clj-kondo/ignore` forms until
they were spelled out; :all itself drops out, having no ignores left.

* Point at :comment-exempt after inserting ignores for a new linter

kondo-insert-ignores writes comment-less ignores, which the justification
ratchet rejects for non-exempt linters.

* Leave ignores preceded by a # for a human, and gate the exemption advice

A `#` immediately before the matched form is ambiguous: in `#^{...}` it is
legacy metadata that removal must take with it, in `x#^{...}` it closes a
syntax-quote gensym that removal must leave alone. Telling them apart means
reading Clojure, which this scanner deliberately does not do -- so report
the site under :skipped, as it already does for prefixless maps and
unbalanced braces, and let a human excise it.

Separately, kondo-insert-ignores no longer advises a :comment-exempt entry
unconditionally. An inserted ignore lands under whatever comment already sat
above the flagged form, so a linter whose sites are all justified would get
an exemption that no-stale-exemptions-test then rejects.

* Align the scan-test expectations

The occurrence vector's :justified? column and the counts map both drifted
out of alignment as linters were added to the fixture.

* Regenerate kondo ignore budgets after restacking onto master
2026-08-27 15:16:59 +02:00
Chris Truter
ec3b24fadd Make every kondo ignore name the linters it suppresses (#79531)
* Make every kondo ignore name the linters it suppresses

The vector-less `#_:clj-kondo/ignore` suppresses every linter on the next
form, so a site added to silence one warning quietly absorbs any other that
appears there later. Replace all 50 with explicit linter vectors.

The budgets absorb what the bare forms were hiding: :discouraged-var
106 -> 129, :unresolved-namespace 3 -> 9, :type-mismatch 2 -> 6,
:unresolved-symbol 4 -> 9, plus first entries for :consistent-alias,
:duplicate-require and :redundant-do. :all drops out entirely, so a new
bare ignore now fails the ratchet with no budget to land in.

* Use the mdb.env alias in app-db-as-data-warehouse

Var-quotes resolve namespace aliases, private vars included, so the
fully-qualified name was never needed. Dropping it retires the
:aliased-namespace-symbol ignore and the unused-alias warning the
workaround caused.

* Regenerate kondo ignore budgets after restacking onto master
2026-08-27 14:23:09 +02:00
Jakub Chodorowicz
7eba2629f2 harden host event listener access across membrane (#81036) 2026-08-27 14:02:06 +02:00
Jakub Chodorowicz
c433278e06 Gdgt 2472 loader (#80586) 2026-08-27 11:53:29 +02:00
Oleh K
11be7cb3e7 Restyle NavLink with primary and secondary variants (#80640)
* Add primary/secondary NavLink variants and sibling components (GDGT-2469)

Restyle the design-system NavLink from Figma:

- Add `primary` (brand-fill selected) and `secondary` (subtle gray selected)
  variants, with Default/Hover/Pressed/Selected states.
- Add the `icon-{primary,secondary,disabled,brand}-inverse` color tokens.
- Add `NavLinkButton` and `NavLinkBadge` sibling components for the right
  section; both invert automatically on a selected `primary` item (no prop),
  the button also has its own hover/press and stops click propagation.
- Give NavLink a `leftIcon` prop so it renders and colors the leading icon
  itself instead of styling the section slot.
- Add a two-variant state-matrix Storybook showcase (Loki-covered).

* Migrate NavLink call sites to primary/secondary variants (GDGT-2469)

- Admin nav: `admin-nav` -> `secondary`
- Entity picker: current level -> `primary`, other levels -> `secondary`;
  search results -> `primary`
- Remove the now-unused `default`/`mb-light`/`admin-nav` variant CSS

* Revert NavLink `leftIcon` prop back to `leftSection` (GDGT-2469)

Drop the polymorphic NavLink wrapper and colour the leading icon via the
`.NavLinkSection[data-position="left"]` CSS overrides again.

* Update Loki Snapshots

* Cleanup

* Update Loki Snapshots

* Add Loki reference for the NavLink Overview story (GDGT-2469)

* Update Loki Snapshots

* Address NavLink review comments (GDGT-2469)

- Migrate the remaining `default` NavLink (embedding SimpleDataPicker) to
  `primary`, restoring its selected styling
- Render NavLinkButton as a `<div role="button">` (valid inside a NavLink's
  `<a>`) with keyboard activation, instead of a `<button>`

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-27 11:42:14 +02:00
Oleksandr Yakushev
cad622a164 [test] Extract and reuse deferred str mechanism for testing macro (#79932) 2026-08-27 10:50:01 +03:00
Ben Grabow
92fdc53695 Only setup tracking db once per jvm (#80960)
* Only setup tracking db once per jvm

* kick ci
2026-08-27 04:53:59 +00:00
Alexander Polyankin
c3dddcfcc6 Encrypt public_uuid at rest, resolved via an indexed prefix (#80929) 2026-08-26 21:08:08 -04:00
Tyler Plude
31e8463f0f [BOT-2019] Harden Metabot SQL editor access (#80544)
* harden sql access

* remove hardcoded sql capability from document context

* make SQL permission denials terminal and visible

* make read-check denials terminal and gate document_schema_collect
2026-08-26 18:06:39 -07:00
Romeo Van Snick
afa13457ae Reset transforms-enabled before the transform permission tests (#80996)
* Reset transforms-enabled before the transform permission tests

* Clear transforms-enabled rather than setting it false
2026-08-27 10:55:52 +10:00
Denis Berezin
7692d3f479 UXW-5117 Fix Timeline event pills displayed on a wrong date when merged (#80567)
* UXW-5117 Implement timeline events stacks

* Improve performance

* Fix edge cases, improve performance, fix issues

* Review fixes

* Place chips at real data point

* Self review
2026-08-27 03:05:06 +03:00
Romeo Van Snick
0a601f07c6 Keep entity identity stable when a write changes nothing (#80976)
* Keep entity identity stable when a write changes nothing

* Skip null field writes in the tables reducer
2026-08-27 09:23:39 +10:00
Romeo Van Snick
cf3200aa96 Load the template tag's field from the API, not the metadata mirror (#80974) 2026-08-27 09:22:55 +10:00
Romeo Van Snick
f5b9b156e8 Move the data reference panes off the metadata mirror (#80973) 2026-08-27 09:22:03 +10:00
Romeo Van Snick
518f75d5f5 Read the picked table's database from the RTK cache, not the metadata mirror (#80972) 2026-08-27 09:21:43 +10:00
Kevin Kelleher
cc1d5a93d1 Preserve timelines modal stack on close (#81037)
turn flat timelines routes into a tree
2026-08-26 19:03:55 -04:00
Andrei Chernykh
3a824943f0 Remove mockdate dependency (#80941) 2026-08-26 15:17:25 -07:00
Ben Grabow
3d7eed5d4a Split postgres driver tests (#81111) 2026-08-26 16:36:55 -05:00
technomancy
cb7114ff8a Stop duplicating databricks test jobs for multi-catalog (#80957)
Stop duplicating databricks test jobs.

In https://github.com/metabase/metabase/pull/57148/changes we added
support for databricks multi-catalog feature. In this PR the existing
databricks job was duplicated, so every databricks test ran twice. The
rationale for this (and more broadly the rationale and background for
the entire feature) was never explained.

The code which looks at the multi-catalog flag looks to be tested in
the metabase.driver.databricks-test test. So running the entire driver
suite all over again just with that one flag set differently looks to
be very wasteful.

We have been putting a lot of load on databricks recently with the
extreme levels of backporting we're doing, which has led to periods
during which 99% of databricks jobs time out. The fact that we're
running twice as many tests as we need to is surely a contributing
factor to this.

It would be even better if the multi-catalog functionality were tested
inside the main test suite instead of spun off into a separate job,
but I want to focus on getting it stable first.

Also we split databricks into 3 jobs, but since the bottleneck is in
the DB itself, this was counter-productive. I've brought it back to 2
jobs like the other cloud databases.
2026-08-26 13:59:16 -07:00
Krishn
af1df1432c Document background task and run statuses (#80578) 2026-08-26 16:54:52 -04:00
Nemanja Glumac
d35b91c92c Run CI only for the base of a stack of PRs (#81098) 2026-08-26 22:13:51 +02:00
Andrei Chernykh
bceebfaee4 Remove dead React 17 compatibility (#81080)
Remove dead React 17 compat
2026-08-26 12:21:23 -07:00
Alexander Polyankin
55d7df2c9c Improve encrypted app-db column handling (#80785)
* Reject plaintext values in encrypted app-db columns via a strict encrypted read

Make the canonical encrypted read path strict: when MB_ENCRYPTION_SECRET_KEY is set,
a value that is not encrypted is rejected instead of trusted, so a value written
directly via SQL cannot stand in for a properly encrypted one. This relies on the
existing aes256-cbc-hmac-sha512 authentication of encrypted values.

- encryption/maybe-decrypt is now strict; the previous lenient behaviour is
  maybe-decrypt-accepting-plaintext. Every existing lenient caller (settings,
  secrets, key rotation, the encryption-check sentinel, migrations) uses the
  accepting-plaintext variant explicitly.
- models.interface: encrypted-json-out / transform-encrypted-json are the only
  (strict) variants. The explorations encrypted-text/EDN transforms are strict too.
- app-db.encryption: the rotation list (encrypted-columns) now also includes the
  exploration_query_result columns, so key rotation and encrypt-db cover them.
- A v63 clojure migration (EncryptEncryptedJsonColumns) encrypts any plaintext
  values left in the encrypted-json columns (e.g. the v58 credentials SQL backfill)
  so strict reads succeed after upgrade.

Secrets and settings keep the lenient path for now and will be handled in follow-up PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Use explicit arities for maybe-decrypt / maybe-decrypt-accepting-plaintext

Replace the [& args] + argument-sniffing with plain [v] / [secret-key v] arities, which
is unambiguous (the value itself can be a byte array).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Make secrets strict; move credentials migration to v58; split decrypt by type

- Narrow the re-encryption migration to auth_identity.credentials only (the sole
  column the v58 SQL backfill wrote as plaintext) and move it to v58 so it backports
  to every affected release. Other encrypted columns are always written encrypted and
  covered by encrypt-db, so they need no migration.
- transform-secret-value is now strict (secrets reject plaintext at rest); encrypt-db's
  secret loop reads raw + lenient so the enable-encryption-later flow still works.
- Split the decrypt helpers by value type like maybe-encrypt/maybe-encrypt-bytes:
  maybe-decrypt / maybe-decrypt-bytes (strict) and their -accepting-plaintext lenient
  counterparts, with String/bytes type hints and explicit arities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Drop the -accepting-plaintext decrypt variants; use strict + explicit try/catch

The lenient variants silently returned plaintext, which conflates "legacy plaintext
(fine)" with "corrupt/invalid" — and only the caller knows which. Notably the
encryption-check sentinel treated a plaintext UUID as :valid when it should be :invalid.

Each caller now uses the strict maybe-decrypt / maybe-decrypt-bytes and handles the
throw explicitly: the sentinel and the startup check treat a decrypt failure as
invalid; key rotation and the secret loop tolerate plaintext (guarding with
possibly-encrypted-*?); settings and migration reads fall back to the raw value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove now-unused metabase.test require in encryption-test

Left over after dropping the lenient-decrypt tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Reintroduce -accepting-plaintext decrypt variants for plaintext-tolerant callers

Bring back maybe-decrypt-accepting-plaintext / maybe-decrypt-bytes-accepting-plaintext so the
plaintext-tolerant callers (settings reads, migration reads, key rotation and the secret loop)
call them directly instead of wrapping the strict maybe-decrypt in try/catch.

The encryption-check sentinel and the startup key-check stay strict, but now express it as an
explicit possibly-encrypted-string? guard + lenient decrypt so a plaintext UUID still reads as
invalid (rather than being trusted) without a try/catch. The core app-db column transforms
(models.interface, exploration) remain strict.

Also apply review nit: maybe-blob->bytes uses cond->.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* encrypt-db: also re-encrypt secret-value BYTES columns (stored_result.result_data)

stored_result.result_data (like secret.value) is a mi/transform-secret-value byte column, read
strictly via maybe-decrypt-bytes. do-encryption only re-encrypted secret.value, so a result_data
written as plaintext before a key was set stayed plaintext, and once a key is set the strict read
throws (500 on GET /api/exploration/query/:id).

List both byte columns and loop over them. Rename encrypted-columns -> encrypted-string-columns and
add encrypted-bytes-columns, splitting the two lists by wire type (string vs bytes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review: stream re-encryption; fail instead of double-encrypting undecryptable values

- `maybe-decrypt-accepting-plaintext` / `maybe-decrypt-bytes-accepting-plaintext` now throw when a value looks
  encrypted but cannot be decrypted with the current key, instead of logging and returning it unchanged. Returning it
  let the key-rotation path re-encrypt it into `encrypt_new(encrypt_old(x))`, permanently unrecoverable. They still
  return genuinely-plaintext values as-is.
- Re-encryption streams rows via `reducible-select` (the string path and a new bytes helper) so a large column such as
  `stored_result.result_data` is not held in memory all at once, and catches the strict decrypt to clear (clearable
  columns) or abort rather than double-encrypting.
- Callers that must tolerate the throw handle it: the `encryption-check` sentinel and `check-encryption` treat a
  decrypt failure as a wrong key; the Setting after-select degrades gracefully on read; `encrypted-json-out` (migration
  reads) returns the raw value on failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix rotate-encryption-key test for the lenient Setting read

The Setting after-select degrades gracefully on an undecryptable value (returns the raw ciphertext) rather than
throwing, so reading a wrong-key setting yields ciphertext, not a thrown exception. Assert not= plaintext instead of
thrown? for that read; the strict Database/User columns still throw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Encrypt api_key hashes at rest (#80902)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 20:43:07 +02:00
github-automation-metabase
a135d86c9a Update translations 2026-08-26 (#81077)
* Update translation sources 2026-08-26

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-08-26 18:41:31 +00:00
Andrei Simionescu
1f807106ab BOT-1013: Correct identifier casing when Metabot fixes SQL (#79674)
Validation transpiled with `identify=True` which quotes every identifier and
freezes its case, so a bare `subtotal` on Snowflake stopped resolving to
`SUBTOTAL`. Keep the LLM's own quoting instead, and only quote reserved words,
folded first so quoting can't change what they match.
2026-08-26 18:21:30 +00:00
Ben Grabow
245862781f Ignore flaky sync failures caused by table deletion race conditions (#81073) 2026-08-26 18:19:24 +00:00
Kevin Kelleher
acaaa73106 Use Markdown for Measure/Segment description fields (#80883)
* markdown for measure and segment descriptions

* Add unit tests that Measure/Segment descriptions render markdown
2026-08-26 17:14:10 +00:00
Ben Grabow
2e58c450be Skip extra server round-trips when loading data (#80958) 2026-08-26 12:13:54 -05:00
Ben Grabow
039a969544 Reuse the impersonation database for each impersonation deftest (#80750)
* Reuse the impersonation database for each impersonation deftest

* Remove dynamic layer of fixture
2026-08-26 16:48:24 +00:00
Ben Grabow
f5ac3e3c64 Fix flaky test caused by noise from batched query executions from other deftests (#80943)
* Eliminate noise from batched query executions from other deftests

* Update ratchets
2026-08-26 16:48:20 +00:00
Riley Thompson
44b811bdcc Use separate page-byte-budget for bigquery query execution and sync (#80849)
* save

* fix bigquery csv downloads

* remove debug
2026-08-26 16:32:51 +00:00
Maksym Yakubych
0421c77905 Normalize scalar search tool arguments (#80107) 2026-08-26 12:24:21 -03:00
Nicola Mometto
bd6f636e35 Quote ClickHouse impersonation role as a single identifier (#80820)
* Quote ClickHouse impersonation role as a single identifier

Stop splitting the role attribute on commas so the whole value is one
role identifier. This removes multi-role activation via a comma-separated
attribute.

* Escape backslashes when quoting the ClickHouse role identifier

A trailing backslash would otherwise escape the closing quote of the
role identifier. Escape backslashes before doubling interior quotes.
2026-08-26 16:17:27 +01:00
Nemanja Glumac
15ef5d5b97 Delete skipped e2e tests (#81011)
* Delete e2e tests tagged @skip

Removes 53 skipped blocks (86 test cases), the oldest of them skipped since
2020, plus the fixtures and helpers they left unreferenced. Three spec files
held nothing but skipped tests and are deleted outright.

The @skip tag itself still exists in the workflows and the matrix builder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the @skip grep tag from the e2e workflows

No spec carries the tag anymore, so the exclusion is dead weight. With
`-@skip` gone the tagged-run expression no longer needs format() to append
it, and collapses to the input or the default tag set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:55:15 +02:00
Phoomparin Mano
b0525d2dc4 Fix tenant collections showing up as unknown in the dashboard question picker (#80824)
* Fix tenant collection question picker

* Fix tenant collection question picker root browsing

* Add tenant collection picker hierarchy

* refactor: extract tenant collection helper module

* Fix tenant collection question picker

* refactor: cleanup unit tests

* refactor: simplify unit tests

* chore: rename synthetic root to collection

* fix: only show root collection if user has access to it

* simplify condition for shouldIncludeRoot

* fix flaky dashboard-tenant-user-question-picker.cy.spec.ts test
2026-08-26 21:51:53 +07:00
Andrei Chernykh
c2f5f3bd7a Remove unused dev dependencies (#80724)
* Remove unused dev-tool dependencies

* Add openapi-typescript back
2026-08-26 07:50:06 -07:00
Paolo Rascunà
a9f25e8645 BOT-2010: Post Metabot's Slack answer as a markdown block (#80652)
* BOT-2010: Post Metabot's Slack answer as a markdown block

Channel answers went in an `mrkdwn` `section`: no table syntax, and a
3000-char cap that cut them short. They now go in a `markdown` block at 12000.

- `final-text-blocks` emits `{:type "markdown"}`, and the answer is elided to
  `markdown-text-limit` (12000) rather than the section limit
- the prompt switches bold and strikethrough to `**text**` and `~~text~~`;
  `*one*` renders italic and `~one~` does not render at all, so the old
  dialect would have silently lost both. Link syntax is untouched --
  `<url|label>` does render inside a `markdown` block (measured)
- the test harness models Slack's real rejection for each block type
  (`invalid_blocks` for `section`, bare `msg_too_long` for `markdown`), and
  charges the markdown budget across the payload as Slack documents it.
  Without this the answer would no longer be checked by anything and the
  limit assertions would pass vacuously

Verified against the live API: a `markdown` block takes exactly 12000 chars,
Slack expands one into several as it renders -- a heading becomes a `header`,
a rule a `divider`, a table a `table` -- and that expansion counts against
the 50-block ceiling, which is why the prompt still bans headings and rules.


Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:40:03 +02:00
Ryan Laurie
70a3c288ed Stabilize Embedding Parameters test (#80761)
* try some stuff

* limit query
2026-08-26 08:07:11 -06:00
Stas Gavrylov
3f2ad8bd17 Upgrade Node.js to 22.23.2 (#80995)
* Upgrade Node.js to 22.23.2

* Use .nvmrc as mise Node version source
2026-08-26 16:46:12 +03:00
Chris Vandoodewaard
fd13d19cad Fix rerun notification: don't notify for PR stacks and thread rerun notifications (#81012)
* don't notify for stacks

* thread Slack notifications

* use short commit

* use run id as key

* add error handling
2026-08-26 09:45:52 -04:00
Nicola Mometto
02a1dc223b Restrict notification markdown link schemes and diff pulse recipients by email (#80597)
* Restrict markdown link URI schemes in rendered notifications

Only http/https/mailto links and images survive markdown rendering for
email/Slack notifications; other schemes and unparseable URIs are
dropped instead of being passed through (or, for malformed URIs when a
site URL is set, throwing mid-render).

* Diff pulse recipients by id or email in the subscription-permission check

Key the added-recipients diff the same way pulse-channel keys
recipients (user recipients by :id, external ones by :email) so
external-address changes are covered by the check too.

* Allow inline base64 images in notification markdown

The app's markdown renderer accepts data:image/...;base64 URIs
(DATA_IMAGE_URI_PATTERN); let notification rendering accept the same
pattern so text-card images look the same in email.

* Cross-link DATA_IMAGE_URI_PATTERN to its backend counterpart

* Share the data-image URI pattern between backend and frontend

Lift the pattern into metabase.util.markdown.image (cljc), compile it
into the frontend bundle via shadow-cljs, and re-export it as
DATA_IMAGE_URI_PATTERN so both renderers accept the same inline images
by construction.

* Apply resolve-uri to Slack images too

* Render a dropped link-ref as its text alone instead of restoring brackets
2026-08-26 12:09:26 +00:00
Kevin Kelleher
da72c9e970 Fix Trend visualization for native MongoDB queries (#80751)
remove trend viz dependency on insights
2026-08-26 07:39:10 -04:00
Sébastien
e83b3f6abe Fix flaky drill-through tests in the sdk iframe embedding spec (#80971)
Scope embed drill-through cell lookups to the table
2026-08-26 13:02:34 +02:00
Fraser
28f667591b Give every bundler the side-effect-free declarations (#80956) 2026-08-26 22:30:21 +12:00
Romeo Van Snick
5a315ad0b4 Move the action creator and embedding data picker off the metadata mirror (#80975)
* Compute the action model's write permission from the API, not the metadata mirror

* Read the embedding picker's source card from the API, not the metadata mirror
2026-08-26 20:12:41 +10:00
github-automation-metabase
a04aaf3714 Fix flaky bulk-table 'edit attributes for db' e2e (#78169)
Clicking the database row in the TablePicker before its expand handler is
wired drops the click, so the schema fetch never fires and cy.wait("@getSchema")
times out with "No request ever occurred." Gate on the expand toggle's
aria-expanded state (collapsed -> click -> expanded) so the schema request
reliably fires before selecting the database — same pattern already proven in
the sibling publish/unpublish test.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-26 12:01:39 +02:00
YB
24a9ec6182 auth-check transform source (#80328)
A transform's :query source is auth-checked against its source database but leaves out some entities named by the query. This checks those too by leveraging on QP's check-query-permissions*.
2026-08-26 09:59:55 +00:00
github-automation-metabase
49716266bf Fix flaky data-model field-description preview hovercard (#78284)
The shared verifyTablePreview helper opened the column-description
hovercard with a single one-shot realHover(). That hovercard opens on a
delayed openDelay timer, so a lone realHover can miss it: after
cy.wait("@dataset") the preview grid re-renders on a microtask and
Chrome v133+ headless hit-tests CDP mouse events differently, either of
which drops the hover and cancels the open timer. hovercard() then only
retries the query, never re-hovers, so the card stays permanently
absent for that attempt.

Replace realHover() with the re-queried synthetic mouseenter+mouseover
dispatch already proven in data-model-shared-1's local verifyTablePreview,
so the events land on the post-render DOM node.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-26 11:34:25 +02:00
Paolo Rascunà
9cd6391ce9 BOT-522: Seed the Slack agent loop with persisted conversation state (#80204)
* BOT-522: Seed the Slack agent loop with persisted conversation state

The Slack loop wrote `metabot_message.state` on every turn but always started
`run-agent-loop` with `:state {}`, so it never read back what it had persisted —
unlike the in-app path, which seeds from `conversation-state`.

This makes sure we feed the state back into the agent loop, filtering out errored messages.

Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:45:44 +02:00
YB
f11ceeac6e [GDGT-3088] gate slack bug reports on bug-reporting-enabled and enforce attribution to session user when non-anon (#80467)
This feature was not following the bug-reporting-enabled gate on the BE previously, and we now enforce using the session user when making a non-anonymous report.

As this is a endpoint that has been in the wild for some time, we'll first report any non-compliance first while overriding with the desired behaviour of only using the session user instead of breaking the API by returning an error
2026-08-26 14:58:41 +08:00
Romeo Van Snick
9b3874e114 Delete the entity-framework hooks (#80969) 2026-08-26 15:52:04 +10:00
Romeo Van Snick
4480309da3 Read a field's remappings through a store-owned selector (#80968) 2026-08-26 15:34:06 +10:00
Romeo Van Snick
c7690123cf Hold a dashboard's click-behavior link targets in dashboard state (#80787) 2026-08-26 12:37:40 +10:00
Romeo Van Snick
3fe2998633 Read the permissions tree's databases from the API, not the metadata mirror (#80403)
* Read the permissions tree's databases from the API, not the metadata mirror

* Record each edit's database in permissions state instead of holding subscriptions

* Take each database field from the endpoint that returns it

Two e2e failures traced to the same cause: no single endpoint returns
everything the permissions tree reads.

GET /api/database is the only endpoint that hydrates router_user_attribute
(warehouses_rest/api.clj:361 and :501). Reading the database from
getDatabaseMetadata therefore dropped it, and the "(Database routing enabled)"
breadcrumb stopped rendering.

GET /api/database/:id/metadata is the only endpoint that returns tables. The
group page's database list passes listDatabases entries, which have none, so
hasPermissionValueInSubgraph found no children and the blocked-child
confirmation never opened.

The mirror had merged both, and held them for the session. getPermissionsDatabase
now merges them explicitly, taking router_user_attribute from the list and tables
from the metadata response. The tables are kept in permissions state rather than
read back from the request's cache entry, which is dropped as soon as the admin
navigates away from that database.

databases.unit.spec.ts pins the merge with fixtures shaped like the two real
responses, which is what the previous specs could not catch: they seeded one
complete database, making the two sources indistinguishable.
2026-08-26 12:18:00 +10:00
Romeo Van Snick
7c8f7b450b Load the whitelabel settings pages lazily (#80788) 2026-08-26 11:59:21 +10:00
Alexander Polyankin
4a7f73029a Store passwords and reset tokens in auth_identity (#80718) 2026-08-25 21:52:54 -04:00
Ryan Laurie
88432c1055 Stabilize table selection in flaky segments/datamodel test (#80681)
* stabilize table selection

* wait on the network requests
2026-08-25 17:18:47 -06:00
Andrei Chernykh
b3bcce1e6b Fix timezone warning for equivalent timezones (UXW-133) (#80131)
Report the results timezone under the requested name when the two are aliases, so charts stop warning about equivalent timezones
2026-08-25 15:31:19 -07:00
github-automation-metabase
5fced6e7da Auto-update documentation for master (#80938)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-25 21:26:11 +00:00
dpsutton
d90b61d26d use authoritative query database id from actions (#80363)
like cards, the actions table has a top level column for database
id. But we can use the canonical value on the action's query and let the
db value be for bulk lookups and as an index. But we make sure to keep
it in lockstep as we go.
2026-08-25 16:09:11 -05:00
Andrei Chernykh
6cd29dedb0 Replace legacy button CSS with Mantine buttons (#80386)
* Replace legacy button CSS with Mantine buttons

* Do not click the disabled map update button in the e2e test

* Polish admin header and add-row wrapping

* Use static view-section paddings instead of clamp
2026-08-25 14:04:03 -07:00
dpsutton
9c2ee7c41d Refuse analytics-dev DB connection/creation unless analytics-dev-mode… (#80901)
* Refuse analytics-dev DB connection/creation unless analytics-dev-mode is on

An :is-audit-dev database is a handle onto the app-db; it carries no real
connection details of its own. Reusing the app-db datasource for it is only
ever valid while analytics dev mode is enabled. Previously the connection
router only *skipped* the datasource reuse when the mode was off, silently
falling through to build a credential-less pool against the wrong host
(surfacing in CI as `FATAL: role "runner" does not exist`).

Make the invariant explicit and fail loudly at every site:
- db->pooled-connection-spec throws for an :is-audit-dev DB when the mode is off
- can-connect? throws likewise instead of reporting the DB as connectable
- create-analytics-dev-database! refuses to create the DB when the mode is off

Tests create/connect these DBs, so they now run under analytics-dev-mode=true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

* Update is-audit-dev routing test for the throw-on-invariant-violation behavior

The connection layer now throws for an :is-audit-dev database reached with
analytics-dev-mode off, rather than silently declining to route it to the app
DB. Update the test's dev-mode-off case to assert the throw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:47:14 -05:00
Riley Thompson
bed8a61829 External PR: db-tables as multimethod to enable driver implementation (#78842)
* clean(materialized-tables): consolidate changes for materialized tables v2

* small changes

---------

Co-authored-by: damienchambe <damien.chambe@gmail.com>
Co-authored-by: Damien Chambe <42462209+damienchambe@users.noreply.github.com>
2026-08-25 16:34:18 -04:00
Chris Vandoodewaard
5a65c04cda add -F msgcat to sort and stablize .po files (#80919)
* add -s to msgcat to sort entries by msgid

* actually sort by file
2026-08-25 16:33:18 -04:00
Denis Berezin
11e969a619 UXW-5034 New UI for database caching settings (#80034)
* UXW-5034 New UI for database caching settings

* Fix issues

* Address feedback

* Address design review fixes

* Fix e2e

* Fix wrapping description text
2026-08-25 23:30:15 +03:00
Ben Grabow
0312bafbc9 Prefix PR title with target version number (#80917)
* Prefix PR title with target version number

* Robot emoji is most important
2026-08-25 19:38:21 +00:00
github-automation-metabase
3eabd5847f Deflake dependency broken list spec (#78600)
* Deflake dependency broken list spec

visitBrokenDependencies did a bare cy.visit with no wait, so tests
immediately queried dynamically-loaded list items with only Cypress's
default 4s timeout - flaky when the breaking-dependencies API is slow.

Mirror the sibling visitUnreferencedEntities helper: intercept the
/api/ee/dependencies/graph/breaking request, wait on it, and assert the
list is visible before the test interacts with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Wait for the test's own broken entities before asserting

The prior deflake waited for the page's breaking-dependencies request, but
the underlying data still raced: setup gated on a bare count
(nodes.length >= 3), which the instance's pre-existing Usage Analytics
breaking dependencies satisfy before the test's transform-table break is
analyzed. The page then rendered only Usage Analytics rows and the test's
"Test Transform Table" / question / model were absent.

Gate setup on the specific expected entities by name instead of a count,
and align the breaking helper to include personal collections (matching its
sibling waitForUnreferencedEntities) so the personal-collection question is
visible to the check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-25 20:59:28 +02:00
Chris Vandoodewaard
b3cddb3504 Notify Slack when "Run Tests" re-runs more than 5 times (#80854)
* add workflow to notify slack on repeated reruns

* fixes

* a few more fixes

* cleanup whitespace

* update to use triggering_actor
2026-08-25 14:36:54 -04:00
Ben Grabow
b1d98f6538 Divide redshift tests first by enterprise/oss, then split by var count (#80789) 2026-08-25 18:29:11 +00:00
Anderson
adc97eaff2 Deflake dashboard title-drill anchor href assertions (#80739)
* Deflake dashboard title-drill anchor href assertions

* Park the cursor without an extra page visit
2026-08-25 17:29:31 +00:00
Ben Grabow
0a3659ea3d Fix error in Summarize test wall times (#80876) 2026-08-25 19:26:09 +02:00
Ben Grabow
4539b47457 Include full log output in profiling bundle (#80848) 2026-08-25 19:24:35 +02:00
Andrei Chernykh
6be03b850b Remove mutationobserver-shim (#80748) 2026-08-25 10:24:23 -07:00
Andrei Chernykh
38346d6268 Remove unused prod dependencies (#80663) 2026-08-25 10:23:46 -07:00
Anderson
6059647f51 Fix flaky Other pie slice drill-through e2e test (#80717)
Fix flaky Other pie slice drill e2e test and skip copy value action for grouped clicks
2026-08-25 13:53:56 -03:00
metamben
87841b3fe6 Compile current time in filters the converted timezone for Postgres (#80244)
Closes QUE2-807
2026-08-25 16:04:25 +00:00
metamben
4e60ba6fad Properly set the report timezone for ClickHouse (#80196)
* Properly set the report timezone for ClickHouse

Closes QUE2-792

* Add clickhouse to the list of tricky to stub drivers
2026-08-25 19:01:58 +03:00
metamben
36fd11b0f7 Take ClickHouse's date bucketing into account (#80110)
Closes QUE2-790
2026-08-25 19:01:46 +03:00
metamben
6ca347e9e2 Enable native pivot support for hive-like drivers (#80333)
* Enable native pivot support for hive-like drivers
2026-08-25 19:01:25 +03:00
Riley Thompson
e58b5a46fb Only rewrite temporal unit on order bys and breakouts (#80124)
* only rewrite breakouts and order bys

* remove test stuff
2026-08-25 12:01:21 -04:00
Egor Grushin
27bfc0d005 Checkbox component redesign (#80262)
* checkbox redesign

* small fix

* fix disabled checkbox visual size

* disallow checkbox labelPosition

* add new loki tests

* update loki snapshots

* fixup! update loki snapshots

* fix e2e specs relying on checkbox Icon-check/Icon-dash classes

* checkbox card border

add border-highlight token, use it for selected card

* disallow checkbox size

single 16px size; xs/sm were both 16px and md was unused

* disallow checkbox card children

* drop redundant role from checkbox icon

* checkbox card body via mantine text/stack

* forwardRef checkbox card

* drop border on disabled checkbox card

* drop disabled checkbox border ring

alpha border over alpha bg stacked into a visible ring

* hide unchecked card indicator icon

mantine hides indicator icon via color:transparent; .icon color override defeated it

* update colors loki snapshot
2026-08-25 16:23:38 +01:00
Ben Grabow
b7322989e3 Use explicit hierarchy for temporal literal classes (#79461)
The java.time classes grouped under ::->absolute-datetime and ::->time were derived in
Clojure's global hierarchy, sharing one mutable namespace with model keywords, hook traits and
every other subsystem -- and registering concrete JDK classes there is especially easy to
collide with.

Move them into a hierarchy var private to this namespace, mutated through derive! -- the same
shape as metabase.events.hierarchy, kept in-file because add-type-info is private and is the
only reader.

The prefer-method calls against Object still apply: class-to-class isa? goes through Java
assignability rather than any keyword hierarchy, so Object continues to match every temporal
class regardless of which hierarchy is in use.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 09:29:38 -05:00
Riley Thompson
45516ce5cf Fix string to boolean literal coercion (#80093)
fix literal coercion
2026-08-25 10:03:43 -04:00
dpsutton
4917982a2d better development against audit db (#80349)
* better development against audit db

* Bump :discouraged-namespace kondo ratchet to 81

connection.clj now requires metabase.audit-app.core with a
:discouraged-namespace ignore (audit-db dev support), so the budget
needs +1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:17:31 -05:00
Jakub Chodorowicz
a4fa8cf2ca harden the custom viz dev server url handling (#80222) 2026-08-25 14:04:21 +02:00
Fraser
b3f2191d7b Prepare visualizations for the viz-core split (#80784) 2026-08-25 23:29:47 +12:00
Chris Truter
858eee14df Add hierarchy visualization renderers (#80618)
* Add hierarchy visualization utilities

* Normalize hierarchy visualization label newlines

* Preserve ordered hierarchy traversal in visualizations

* Format hierarchy utility docstrings

* Move hierarchy visualization utilities to dev

* Move hierarchy visualization tests alongside the dev namespace

Also sort nodes unreachable from a root, so malformed hierarchies still
render in a stable order, and mark the hierarchy tests parallel.

* Keep hierarchy visualization tests discoverable by the test runner

The runner skips `dev` directories and non-`metabase.*` namespaces, so a
`dev.*` test never runs. Follow `metabase.core.modules-test` and
`metabase.core.kondo-ratchet-test`, which test dev namespaces from a
`metabase.*` test namespace, and say so in the namespace docstring.

* List hierarchy nodes without drawing a tree

`nodes` returns every tag as a flat vector, leaves-to-roots by default so a node
precedes its parents, or alphabetically on request. Ordered hierarchies break ties
by derivation order and the rest fall back to `pr-str`, both of which `hierarchy->graph`
already worked out, so there is no separate code path for the two kinds.

A test pins the topological order to `ordered-hierarchy/sorted-tags` so the dev output
cannot drift from the order the runtime uses.
2026-08-25 13:20:48 +02:00
Sébastien
5ae667c0b0 Fix flaky JWT user-session e2e in sdk iframe embed setup get-code (#80642)
* Fix flaky JWT user-session e2e in sdk iframe embed setup get-code

* Assert terms acceptance with a plain not.exist check
2026-08-25 13:08:50 +02:00
Chris Truter
a5bd647dc4 Fix ordered hierarchy ordering and validation (#80720)
* Fix ordered hierarchy ordering and validation

* Format hierarchy utility docstrings

* Mark ordered hierarchy tests parallel

* Return nil from first-common-ancestor when tags share no ancestor

Passing a tag with no ancestors as the second argument made the lookup call nil as a
predicate, so disconnected roots and tags outside the hierarchy threw an NPE instead of
returning nil.

* Reject children declared after a tag's first occurrence in any basis

The check only ran for nested vectors, so a tag introduced in one basis could pick up
children in a later one. Hoisting it into derive-basis applies the same rule to every
top-level basis.

* Ratchet down kondo ignore budgets

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-25 12:49:56 +02:00
Nicola Mometto
79c18e5f89 Check ChannelTemplate write permission on notification handler templates (#80469)
The notification create/update endpoints let a handler attach an existing
ChannelTemplate by id, or carry an inline template that creates, overwrites,
or deletes one, but didn't apply the permission check used when writing a
ChannelTemplate directly. Gate any such template write on
mi/can-write? :model/ChannelTemplate, mirroring the existing inline-channel
check. Seeding and the pulse->notification migration go through the model
layer directly, so they're unaffected.
2026-08-25 10:07:57 +00:00
Nemanja Glumac
4bcfa01f00 Fix kondo ratchets total ignored forms count (#80811) 2026-08-25 11:33:56 +02:00
Fraser
dd1cc22c22 Early exit the loki stress test when every changed story is loki-skipped (#80790) 2026-08-25 20:51:51 +12:00
Nicola Mometto
421f547421 Require native query permission to author native query actions (#80612)
POST /api/action and PUT /api/action/:id now require the current user to
have ad-hoc native query permission on the target database when the
submitted dataset_query is native.
2026-08-25 09:24:19 +01:00
Fraser
4071663479 Tidy module side-effects lint rule (#80734) 2026-08-25 14:12:47 +12:00
Romeo Van Snick
b9846a5b64 Load enterprise route pages through plugin route slots (#80268)
* Load the enterprise admin pages through plugin route slots

* Load the tenant collection pages through plugin route slots

* Resolve every plugin route slot in a test

* Name the chunks the plugin route slots load
2026-08-25 12:12:02 +10:00
Nemanja Glumac
adf654542a Remove ability to skip test suites upfront (#80760) 2026-08-24 17:49:51 -06:00
Fraser
e8b4febba7 Move session and subscription request types into metabase-types/api (#79346) 2026-08-25 11:27:19 +12:00
Fraser
ecdaad1ddc Prepare the visualization types for a viz-core module (#80404) 2026-08-25 11:25:00 +12:00
Alexander Polyankin
1fbd338057 Check revision revert perms against the table of the restored definition (#80553)
Check revert perms against the table of the restored definition

For Segment and Measure reverts, table_id is re-derived from the
definition on update, so check write perms against the source table of
the restored revision's definition when it has one, falling back to the
revision's stored table_id otherwise.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 19:05:04 -04:00
Alexander Polyankin
a408a8822c Default undeclared endpoint params to an empty map schema (#80575)
* Default undeclared endpoint params to an empty map schema

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Ignore unparsed request bodies instead of declaring them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pass multipart params as the request body of multipart endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Declare query and route params read by dashboard and database endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Ignore the kebab-case linter for newly declared snake_case query params

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 19:04:46 -04:00
Ben Grabow
2258a06f60 Don't create nested transaction which would block view of task progress (#80402) 2026-08-24 17:29:18 -05:00
github-automation-metabase
ad801f22e9 Auto-update documentation for master (#80733)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-24 21:22:09 +00:00
Alex Yarosh
037092060e docs: remove billing page (#80722)
* remove billing

* tending to links
2026-08-24 17:00:29 -04:00
Jeff Bruemmer
93d7163cd2 docs - embedding dashboards (#79915) 2026-08-24 19:28:20 +00:00
Anderson
0361fce560 Fix flaky remote-sync conflict-modal e2e test (#80396)
* Anchor remote-sync conflict-modal test on the async conflict lifecycle

* Reword test comments per writing conventions

* Trim comments and move timeouts to request/test-level options

* Use navigation helper when navigating to the transforms page

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fail fast on terminal sync task statuses

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-24 15:52:21 -03:00
Nemanja Glumac
5e53c6b689 Reduce flakiness of table-editing e2e tests (#80598) 2026-08-24 20:52:18 +02:00
Ben Grabow
6fa6eee688 Debounce tracking table writes (#80662) 2026-08-24 18:35:28 +00:00
github-automation-metabase
8339e57d2c Fix detached-DOM flake in upload table deletion e2e test (#78293)
* Fix detached-DOM flake in upload table deletion e2e test

Re-query the upload-tables-table container on each assertion instead of
caching it via within(). The table re-renders when getUploadTables
resolves, detaching the node within() captured, so a retrying query
against the stale subtree got stuck on an intermediate render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Appease the linter

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
2026-08-24 20:32:30 +02:00
Nemanja Glumac
4b8fd9848e Cover DB management permissions with targetted tests (#80594) 2026-08-24 20:25:17 +02:00
Phoomparin Mano
4852413258 Check embedding saml popup source (#80494)
* check saml popup origins

* throw when site-url does not match instance-url

* Fix SAML embedding popup validation
2026-08-24 17:39:25 +00:00
Nemanja Glumac
eb401d8b6e Skip drivers-decision analysis on force-run (#80617) 2026-08-24 19:31:26 +02:00
Chris Truter
59b8696296 Fix test-user caching across appdb swaps (#80436)
* Key cached session tokens on the app db

with-empty-h2-app-db! swaps in a different application database, and this cache was keyed on the username
alone. A token minted against the previous database names a session that does not exist in the new one, so
the request 401s, and the token minted inside leaks back out when the original is restored. The retry in
client-fn cleared the whole cache and tried again, which papered over it often enough to look like flake:
"Failed to authenticate X after three tries".

Key it on the app db as well, like every neighbouring cache does via memoize-for-application-db.

databases-list-can-upload-test and dismiss-spinner-test go from failing to passing; both were in the CI
failure list, the former across 12 jobs.

* Track globally-created test users per application database

with-empty-h2-app-db! swaps in an empty app db, where the users another database created do not exist.
`created-globally?` was a boolean, so it stayed true across the swap and user->id kept handing back ids from
the previous database. Authenticating then inserted a Session for a user that was not there:

  Failed to authenticate :crowberto after three tries: Referential integrity constraint violation:
  FK_SESSION_REF_USER_ID: PUBLIC.CORE_SESSION FOREIGN KEY(USER_ID) REFERENCES PUBLIC.CORE_USER(ID) (1)

Track the set of app dbs that have them instead. The memo beside it was already keyed this way via
memoize-for-application-db; only the flag was not.

databases-list-can-upload-test failed 8/8 before this and 0/8 after; the whole namespace now passes.

* Say what the token cache holds
2026-08-24 19:20:47 +02:00
metabase-bot[bot]
ff25772d0c [chore] Update E2E test timings with recent data (#79933)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: iethree <30528226+iethree@users.noreply.github.com>
2026-08-24 13:13:09 -04:00
Nicola Mometto
4a47fda8d8 Reject impersonation role equal to the driver default-role sentinel (#80616)
Reject impersonation role attribute equal to the driver default-role sentinel

connection-impersonation-role now rejects a role attribute that (case-insensitively)
equals the driver's default-database-role sentinel (e.g. NONE / DEFAULT), which would
otherwise emit SET ROLE NONE and drop the connection to the base warehouse credential.
2026-08-24 17:24:16 +01:00
Alexander Polyankin
70504458f6 Strip template tags from public and embed native queries (#80574)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:19:28 -04:00
Alexander Polyankin
54d059982c Resolve card UI parameter fields from param_fields for saved cards (#80569)
* Resolve card UI parameter fields from param_fields for saved cards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resolve parameters against the query for questions opened from a dashboard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:19:27 -04:00
Alexander Polyankin
2350e6a952 Remove unused data from embed and public endpoints (#80510)
* Remove unused data from embed and public endpoints

Public and static-embed card/dashboard/document endpoints now return a
minimal placeholder dataset_query (a blank native query against the same
database) instead of selecting individual query keys. The frontend only
needs the database id here. Parameter combining and :param_fields
hydration in public-card now run before the card is trimmed since both
read template tags from the full query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add tests for embed card and dashboard dataset_query

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert strictly on dataset_query in public and embed tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Handle native queries in Table viz

* Fix column shadowing in HeaderCellWithColumnInfo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use param_fields for public question parameters

Public and static-embed card payloads carry param_fields instead of the
real query, so PublicOrEmbeddedQuestion now builds UI parameters from
them via getCardUiParametersFromParamFields, mirroring the saved-mode
dashboard path. The query-based helpers are renamed to the FromQuery /
FromParamFields scheme on both the card and dashboard sides, and the
ParameterWithTarget casts are dropped now that getParameterTargetField
accepts an optional target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use param_fields for embed setup card parameters

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use param_fields for guest embed SDK question parameters

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route card UI parameters by param_fields presence

Restores getCardUiParameters as the entry point: it delegates to
getCardUiParametersFromParamFields when the card carries param_fields
(public and static-embed payloads) and to the query-based variant
otherwise. The SDK SQL parameter surfaces use it since they serve both
guest and authenticated contexts. Also reverts the dashboard helper
renames back to getSaved/getUnsavedDashboardUiParameters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix preview embed card param values

The preview-embed card values and remapping endpoints get their card via
card-for-unsigned-token -> public-card, which now replaces the query with
a blank native placeholder, so field-filter parameters could no longer be
resolved and the response schema rejected the nil result. Bind
qp.perms/*param-values-query* around the card fetch, the escape hatch
remove-card-non-public-columns already honors, matching the public
sharing values endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add preview embed test for remapped parameter fields

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix

* vscode fix

* Backfill template tag parameters in param_fields UI parameters

GET /api/card/:id returns parameters as stored, unlike the public and
embed endpoints which combine them with template tags, so
getCardUiParametersFromParamFields now derives parameters via
getParametersFromCard like the query-based variant always did. Guest
embed payloads are unaffected: their parameters arrive already combined
and the blank query derives nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use getCardUiParameters dispatcher in SDK load-question

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard applyTemplateTagParameters by param_fields presence

Cards whose payloads carry param_fields have parameters already combined
with template tags by the backend, so re-applying from the query would
wipe them. Keying the guard on the card payload keeps it in sync with
how the card was loaded, replacing the isGuestEmbed flag checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use getCardUiParameters everywhere and build test queries via Lib

Question.parameters and the query builder call sites go back to the
getCardUiParameters entry point, which now routes by param_fields
presence, leaving those files unchanged relative to master. The
template tag fallback test builds its query with
Lib.createTestJsNativeQuery instead of an inline legacy map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make getCardUiParameters the only entry point

The FromQuery and FromParamFields variants are private to cards.ts now;
all consumers call getCardUiParameters, which routes by param_fields
presence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Build preview embed remapped-fields test query via lib

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restore null fallback in useAvailableParameters

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix

* Preserve query type in stripped public and embed card queries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add placeholder aggregations to stripped MBQL queries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep parameter template tags in stripped native queries and revert FE workarounds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Export parameter-template-tag? via import-vars

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add test for :param_fields hydration on GET /api/card/:id

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:19:27 -04:00
Andrei Chernykh
c0ed9ab9af Remove unused @types/ packages (#80568)
Remove orphaned @types stubs, move @types/js-yaml to devDeps
2026-08-24 08:50:48 -07:00
Chris Truter
6482f14a58 Respect Metabot permission mode when resolving access (#80468)
* Clear All tenant users metabot permissions when switching to group-level mode

Switching metabot permissions to group-level only wiped the All Users magic group's rows.
Under tenants the simple mode also writes to All tenant users, and since resolution takes the
most permissive value across a user's groups, a leftover :yes there overrode every group-level :no.

POST /advanced now clears both magic groups; DELETE /advanced keeps both instead of dropping the
tenant toggle. The confirmation dialog names both groups when tenants are on.

Fixes #80394

* Backfill the tenant permission cleanup and make both mode-switch dialogs tenant-aware

Instances that switched to group-level mode before the fix still carry the seeded all-yes rows
for All tenant users, so a migration clears them wherever metabot-advanced-permissions is true.

The disable dialog now says both magic groups keep their access under tenants, and both dialogs
use the "All internal users" name the permissions UI shows when tenants are on.

* Explain why the tenant-permission migration only reads the setting row

* Move the tenant-permission migration to 063 for the single backport

* Move the tenant-permission migration to 061 so it backports to every affected release

* Scope metabot permission resolution to the groups the current mode shows

Resolution took the most permissive value across every group a user belongs to regardless of
mode, so any row on a group the admin UI hides -- the All Users and All tenant users rows in
group-level mode, the seeded data-analyst rows and any API-written group in simple mode --
silently overrode every visible :no. The write-site fixes (deleting rows on POST /advanced, the
backfill migration gated on the setting row) only covered the paths that went through the
endpoint; PUT /api/setting, serdes import and PUT / on a hidden group all bypassed them.

Filter in the resolver instead: simple mode only counts Administrators, All Users and All tenant
users; group-level mode counts everything but those two default groups. Hidden rows stay in the
table and come back into force on switching modes, so POST /advanced no longer deletes them and
DELETE /advanced genuinely keeps them, which also restores the simple-mode values after a round
trip instead of leaving the default groups at all-no. With leftover rows inert the migration is
no longer needed.

* Leave the mode-switch dialog copy as it was

* Refuse the metabot permission mode switch while an env var forces the setting

The setter writes the row regardless, but the env var keeps winning on read, so DELETE /advanced would
delete every group's rows and leave the instance in group-level mode with nobody but admins allowed in.

* Flip the metabot permission mode before dropping the group-level rows

Deleting first meant a failed setting write left group-level mode in place with its permissions already gone. In the new order a failed delete only leaves rows that simple mode ignores.

* Say in the docstrings that resolution only sees the active mode's groups

* Clear the default-group rows again when switching to group-level mode

Preserving them so they returned on a switch back was a silent widening: the 061 migration seeds every default group all-yes, so a round trip through group-level mode handed everyone AI access again, including the tenant rows that caused this bug.

Both mode-switch dialogs promise removal, and nothing in the docs describes a mode remembering its old configuration, so the modes now delete the rows the other one owns, as before. Resolution stays scoped to the active mode's groups: that is what makes rows created by other write paths, and the rows already sitting on instances in the field, harmless without a migration.

* Switch the metabot permission mode in one transaction

Ordering the two writes only chose which half-applied switch an admin got: the mode changed while the rows did not, or the rows went while the mode stayed. The setting write goes last inside the transaction, since it updates the settings cache in place and a rollback would leave the new value there.

* Reload the settings cache when a mode switch rolls back

The setting write updates the cache in memory, which the rollback does not undo, so a commit failure would leave this node serving a mode the database does not have.

* Cover the settings cache in the mode-switch rollback test

The redefined setter now writes the setting for real before throwing, so the test fails if the rollback leaves the new mode in the cache.

* Extract the mode's group list to one definition

Both the resolution query and the mode-switch endpoints had their own copy of the
Administrators / All Users / All tenant users list, and read as opposites: one
selected with :in, the other deleted with :not-in. They now share
visible-groups-clause, with hidden-groups-clause defined as its complement.

* Improve Metabot permissions documentation

* Address Metabot permissions review feedback
2026-08-24 15:01:14 +00:00
Tyler Plude
3cd5fc8e1e [BOT-1899] Metabot DeepSeek direct provider support (#80054)
* initial deepseek support

* [BOT-1899] Port DeepSeek onto the LLM provider connection registry (#80100)

Wire DeepSeek into the LLM provider connection registry

DeepSeek arrived as an adapter reading its own credential settings, which is how
providers worked before the connection list. Make it a connection type like the
rest:

- `deepseek` joins `metabase.llm.provider`'s registry with the api-key and
  base-url fields the admin connection form renders itself. No key prefix:
  DeepSeek keys carry the same `sk-` an OpenAI key does, so matching on it would
  claim OpenAI keys and reject nothing.
- `llm-deepseek-api-key` / `-api-base-url` become the type's single-provider
  environment variables, reading and writing through the connection they
  configure like every other provider's.
- The adapter takes `:credentials` off the request instead of reading settings,
  so two DeepSeek connections can hold different keys.
- The frontend mirrors the new type name and gets a DeepSeek mark.

The app-db migration that folds the old per-provider settings into the
connection list is a frozen snapshot of the registry as it stood then, and stays
untouched: no released version wrote a DeepSeek setting, so there is nothing to
adopt, and a rollback drops the connection because the versions it rolls back to
cannot serve DeepSeek either. A test pins that.

Regenerated the settings docs, and listed DeepSeek among the providers you can
bring a key for.

* revert docs changes

* review feedback

---------

Co-authored-by: Maksym Yakubych <maksym@metabase.com>
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
2026-08-24 10:35:50 -04:00
Kamil Mielnik
5f8ff14492 GDGT-3141 [Flaky Test]: should render saved top level blocks (#80614) 2026-08-24 21:24:04 +07:00
Sloan Sparger
185e6d7878 [Metabot] Add prompt toggle button to the SQL editor header (#80379) 2026-08-24 11:08:59 -03:00
Stas Gavrylov
a90f1170ba GDGT-2460 Update Toast styles (#80327)
* GDGT-2460 Update Toast styles

* GDGT-2460 Port Toast to Mantine Notification

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-24 16:46:10 +03:00
github-automation-metabase
3af49a24f7 Auto-update documentation for master (#80555)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-24 09:39:32 -04:00
Chris Vandoodewaard
c15af0329d update driver-test-results with missing driver tests (#80551)
* add dependency on all driver tests

* sort the list
2026-08-24 13:28:01 +00:00
Chris Vandoodewaard
19747d61b0 use CI Conductor base url from vars instead of secrets (#80552) 2026-08-24 09:23:59 -04:00
Kevin Kelleher
3235182102 Remove group prefixes from Explorations, use curated dimension display name (#79603)
* Remove group prefixes from dimension labels, use curated display-name

* fix error after merge
2026-08-24 13:14:04 +00:00
github-automation-metabase
e1ed14d77f Fix flaky native database source selectDatabase detach (#78266)
* Fix flaky native database source selectDatabase detach

The database picker popover opens automatically and re-mounts its rows as the database list finishes loading, which could detach the target row while Cypress waited for it to become actionable ("elements ... disappeared from the page"). Anchor the click in selectDatabase behind a visibility assertion so Cypress re-queries the popover chain on detach and clicks a freshly attached, settled row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Force-click the native database picker row to avoid detach

The database picker popover opens automatically and, as GET /api/database
resolves, its onQueryStarted hook re-writes shared metadata. That hands the
selector a fresh databases array reference and re-mounts the AccordionList rows.
The previous visibility-anchored click still entered Cypress's actionability
wait, during which the target row detached ("elements ... disappeared from the
page"). Click the row with { force: true } so Cypress fires the moment the row
is found and visible, skipping the actionability wait window in which the
re-mount can detach it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com>
2026-08-24 14:18:45 +02:00
Paolo Rascunà
638d0e4cb2 BOT-1606: Truncate over-long Metabot answers instead of going silent (#80105)
* BOT-1606: Truncate over-long Metabot answers instead of going silent

`channel/final-text-blocks` put the whole answer into a single Slack `section`
block. Slack caps a section's `text.text` at 3000 characters, so any longer
answer came back as `invalid_blocks` ("must be less than 3001 characters") and
the thread simply went quiet -- no answer, no error, nothing to retry.

Elide the answer at the limit, and when that actually cuts it, follow it with a
`context` block saying so and linking to the full answer in Metabase. The notice
is deliberately kept out of the message's `:text`, which `thread->history`
replays back to the model as the assistant's own words.

The answer stays a single mrkdwn `section` block, as before. Answers that already
fit are byte-for-byte unchanged: `u.str/elide` returns its input untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* BOT-1606: Address review comments on the truncation notice

Works through appleby's review nits on #80105:

- Docstring summary for `truncation-notice` is one sentence (continuation
  aligned to 3 spaces, matching the rest of the file).
- The notice now names the limit, so a reader can judge how much to trim:
  "This answer was longer than 3000 characters."
- Adopted the suggested "...and try again." ending.
- Added a debug log on the truncation path, recording the answer length
  against the limit.
- Moved the shared `oversized-answer` fixture into `slackbot.test-util`;
  `channel_test` and `api_test` had identical copies.
- `slack-section-text-limit` now derives from the production constant
  rather than restating it, which makes the drift test redundant.

`log/debugf` evaluates its args only once the level is enabled, so the
debug log was verified through the REPL with log capture on rather than
through the suite, which cannot reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm

* BOT-1606: Cap the visualization title so Slack accepts the message

The truncation shipped so far only guarded the answer block. The error in
BOT-1606 names `/blocks/1`, which is the visualization title block, not the
answer: `format-viz-title` embeds a `/question#<base64-query>` link, and for
a large query that base64 alone runs past Slack's 3000-character section
limit, so `chat.postMessage` rejects the whole message.

Reproduced with a native query of the size Metabot routinely writes: a
7,119-character SQL string yields a 9,770-character link and a 9,820-character
title block, and Slack returns the reported error verbatim.

A cut URL is a broken URL, so drop the link rather than truncate it and keep
the title; with no title left, `viz-output->blocks` already falls back to
"Query results". `section-text-limit` becomes public for this -- streaming
already required the channel namespace, so no new dependency.

Both this and the answer truncation log at `info` rather than `debug`:
`metabase.slackbot` inherits INFO from log4j2.xml, so a debug line there is
invisible on a default instance. Link drops get their own counter rather than
reusing `responses-truncated`, which counts responses; a long answer and a
long query link are different problems and worth telling apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm

* BOT-1606: Fall back to plain text when Slack rejects the blocks

`send-channel-response` logged a failed `chat.postMessage` and stopped
there, so a user whose message Slack refused saw the "is doing science..."
status and then nothing. `send-dm-response` already retries with a
plain-text reply; do the same here, with the same wording.

A plain-text reply carries no blocks for Slack to reject, so it survives
whatever made the block message fail -- including causes we have not
anticipated and so cannot cap the way the answer and viz title are capped.

Add `responses-undeliverable`, incremented from both paths when the
fallback fails too. It counts only total delivery failure: a response the
fallback rescues is deliberately not counted, so the metric answers "how
often did a user get nothing at all", which is the alertable question.

Left the error-cleanup fallback in `send-dm-response` uncounted -- that
path is already reporting a thrown exception, and its fallback carries an
error notice rather than an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm

* BOT-1606: Cap alt_text, and stop miscounting title-only elisions

Follow-ups from a Clojure review of the branch.

Slack caps an image block's `alt_text` at 2000 characters, not the 3000 it
allows a section block, and `viz-output->blocks` passed the raw title
straight through. A title of 2001-3000 characters therefore cleared
`format-viz-title` untouched -- comfortably inside the section limit -- and
then failed `chat.postMessage` with `invalid_blocks`: the same silence this
branch exists to fix, reachable through the function it hardened. Cut the
title with `limit-chars` rather than `elide`, since alt text is plain and an
ellipsis would only eat into the budget.

`format-viz-title` also reported a dropped link whenever the rendered title
was over the limit, including when there was no link at all -- logging
`link_length=0` while claiming a drop, and incrementing `viz-links-dropped`
for something that never happened. Both cases still elide the title; only the
reporting now branches.

Docstrings: several first lines ran on into the next line, which
https://guide.clojure.style/#docstring-summary asks not to happen. Also
`format-viz-title` claimed to return nil only when given neither a title nor
a link, though it also returns nil for a link too long to keep -- as the
branch's own test asserts -- and `slack-section-text-limit` restated the
sentence from the var it aliases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm

---------

Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:01:11 +02:00
github-automation-metabase
1081fb8c02 Fix flaky alerts toggle e2e in sdk iframe embed setup select-embed-options (#78285)
* Fix flaky alerts toggle e2e: re-query preview iframe for Alerts click

The 'toggles alerts for question when email is set up' spec cached the
embed preview iframe body via within() and chained the Alerts button
visibility assertion straight into the click. Toggling the sidebar
options re-renders the embedded question, so the cached body and the
found ActionIcon detach mid-click ('the page updated while this command
was executing'). Re-query the iframe body fresh and split the visibility
assertion from the click so Cypress retries against a stable element.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Give re-render-dependent alerts lookups a longer timeout

Iteration 1 removed the mid-click detach by re-querying the preview
iframe body, and 49/50 burn-in runs passed. The residual failure is a
different flake: toggling a sidebar option re-renders (and re-runs the
query of) the embedded question inside the already-loaded iframe, and
that round-trip occasionally exceeds Cypress's default 4s command
timeout, so findByRole for the "Alerts" button times out with only a
"group" role present in the iframe body. Give the Alerts button and
alert-modal lookups that depend on a re-render a 15s budget so Cypress
waits the slow re-fetch+render out instead of asserting mid-render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sébastien <sebastien@metabase.com>
2026-08-24 13:13:39 +02:00
github-automation-metabase
4ba63550a4 Harden embedding-hub sandbox column-update e2e flake (#78620)
The "should update existing sandboxes when changing column selection"
test raced in two ways:

- It waited on @updatePermissionsGraph with the default 5s request
  timeout, but the "Next" click first runs several sequential metadata
  fetches (list tables, list databases, permissions-graph revision)
  before issuing that PUT, so under load the request can appear later
  than 5s and the wait times out. Give it a 15s timeout.

- It asserted H.undoToast().should("not.exist") with no positive anchor,
  which can pass against an unrendered page. Anchor it on the positive
  success signal ("Select data" step marked complete) first.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sébastien <sebastien@metabase.com>
2026-08-24 13:00:31 +02:00
Phoomparin Mano
582d66e9c1 Allow embedding sdk target for saml popups (#80479)
* check for origins in saml popups

* log origin of login
2026-08-24 10:46:48 +00:00
Ngoc Khuat
04b84f8b77 Add connection and read timeouts to python-runner requests (#80080)
* Add connection and read timeouts to python-runner requests

* Tighten timeout docstrings

* Cancel the runner on a client timeout, retry transient log-poll timeouts

Review follow-ups on the python-runner timeouts:
- a client-side timeout on /execute now fires /cancel, so the runner
  stops working on a run nobody is waiting for
- a read timeout while polling /logs is retried instead of ending the
  poll loop for the rest of the run
- use u/seconds->ms instead of inline math
2026-08-24 16:34:27 +07:00
YB
9adb94e945 [GDGT-3085] adhere to HTTP put spec for notification API (#79821)
PUT /api/notification/:id accepted a different id from what was in the URL which diverges from normal PUT behaviour. This fix aligns it with how we've handled PUT updates in this codebase by only handling allowlisted params for the update.
2026-08-24 09:10:26 +00:00
github-automation-metabase
9139563930 Deflake SDK question data-picker target-collection test (#78105)
* Deflake SDK question data-picker target-collection test

The "change target collection to a different entity id" test blindly
pressed Escape then clicked "Pick your starting data" to open the data
picker. In the embedding SDK the DataStep always renders the data picker
and auto-opens it when no source table is selected, and that open state
settles asynchronously after the collection re-render. When the popover
auto-opened after the blind Escape, the follow-up click toggled it back
closed, so H.popover() never appeared and the Orders lookup timed out.

Wait until the popover is actually gone after Escape before reopening it
from a known-closed state, and use visibility anchors instead of exist,
removing the toggle race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Deflake SDK question data-picker target-collection test

The embedding data picker uses isInitiallyOpen={!table}; with questionId="new"
no source table is ever selected, so the picker auto-opens on every re-render and
stays open. The prior approach toggled it (esc to close, click to reopen), which
raced the async auto-open: sometimes the click toggled the just-opened popover
shut, sometimes esc failed to close it at all. Interact with the already-open
popover directly to remove the toggle race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 10:55:14 +02:00
Fraser
dd308471eb Remove the dead push-event arms from the affected-tests stats upload (#80583)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:37:02 +02:00
metabase-bot[bot]
5f354e3df2 [chore] Update E2E test timings with recent data (#80580)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-08-23 20:28:05 +02:00
Ben Grabow
fbc2d9d323 Use explicit hierarchy for metabot reaction types (#79465)
Reaction types registered by defreaction derived from :metabot/registered-action in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move them into a hierarchy var private to this namespace, mutated through
derive! -- the same shape as metabase.events.hierarchy, kept in-file because defreaction is
the only writer and known-reaction-types the only reader.

This also narrows the schema-rebuild watch. It was attached to #'clojure.core/global-hierarchy,
so it woke on every derive anywhere in the application and compared descendant sets to decide
whether anything relevant had changed; now it only fires for reaction registrations.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:57:54 -05:00
Ben Grabow
c24af85666 Use explicit hierarchy for search engines (#79462)
The legacy-cookie alias :search.engine/fulltext -> :search.engine/appdb lived in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move it into metabase.search.hierarchy, mirroring metabase.events.hierarchy:
a defonce var holding a hierarchy map, mutated through derive!.

All thirteen engine multimethods now dispatch on that hierarchy, and known-engine? walks it
rather than the global one.

The hierarchy has a single writer inside the search module, so it exposes only derive! and
ancestors -- the operations that are actually used.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:22:19 -05:00
Sloan Sparger
1d961d5cc5 [Metabot] Remove dead chart analysis code (#80371) 2026-08-21 22:50:30 +00:00
github-automation-metabase
df58523076 Fix flake in writable-connection model persistence e2e test (#78277)
The model persistence test clicked the global and per-database persistence
toggles without waiting for their requests to complete, then immediately
POSTed to persist a card. The card-persist endpoint returned 400
("Persisting models not enabled for database") when it raced ahead of the
per-database setting, and the per-database toggle could fail to render when
the database page booted from a stale global setting.

Intercept and wait on both persist-enable requests before proceeding.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 00:48:17 +02:00
github-automation-metabase
b9292b6b6c Deflake notifications unsubscribe test on modal entrance transition (#78580)
The "unsubscribe from an alert when the user has not created it" test
clicked the Unsubscribe button immediately after a bare findByText for
the confirm modal's title. That query resolves the instant the title
mounts, while the Mantine modal is still animating in and its title
transiently covers the button's click point, so cy.click() intermittently
failed with "covered by another element".

Anchor on the title being visible first so the click is deferred until
the modal's entrance transition has settled.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-21 16:08:43 -06:00
Sloan Sparger
bd86545b79 [Metabot] Remove image_base_64 context (#80364)
remove image_base_64 context
2026-08-21 17:03:48 -05:00
Sloan Sparger
92ab58c480 [Metabot] Remove explain chart button (#80355)
* remove explain chart

* update docs

* doc change
2026-08-21 17:03:48 -05:00
John Swanson
319a3664f9 Guard queue declarations by ownership, not config equality (#80388)
Re-running mq init in a REPL (dev server startup plus test init, or a
retried :mq test-init step) threw for the exploration queues:

```
  Queue :queue/exploration-plan is already registered with different config.
```

This is because of an attempt to catch cases where we were declaring
the same queue multiple times with different config. However, this was
subtly broken in two ways:

- first, there are two phases to queue registration: declaration, and
  registration. Declaration just means "write down what queue X's config
  is." Registration means actually executing that. Our check ran on
  *registration*, not on declaration. So if there were two different
  calls to `def-queue!` for the same queue, the first one would declare
  its `def-queue*`, then the second would overwrite it, and there would
  be no meaningful check left.

- second, doing registration twice would always throw, because some
  queue configs contained functions, and declaring the function twice
  resulted in non-equal results.

Instead, we want to just check that we're not *declaring* the same queue
multiple times in different namespaces. We allow declaring the same
queue twice in the same namespace for REPL convenience - technically a
loophole, but this is similar to a check we have for `defsetting`s and
it doesn't seem to have been a problem.
2026-08-21 14:41:38 -07:00
John Swanson
e964676a23 Pin exploration auto-binning to an explicit bin width (UXW-4391) (#78060)
* Pin exploration auto-binning to an explicit bin width (UXW-4391)

Exploration charts fan one metric/dim query out into several
segment-filtered variants that share an x-axis. With {:strategy :default}
binning, the QP re-derives the bin width per variant from that variant's
own filters, so a segment that range-filters the binned column (e.g.
"Highly rated products", rating >= 4) got 0.125-wide bins while its
siblings got 0.75-wide bins.

Fix: resolve the width the QP would use for the unsegmented query
(card-level filters included, per-segment filter excluded) and pin it on
the breakout as {:strategy :bin-width}. The binning middleware never
renegotiates an explicit bin width, and bin edges snap to multiples of
the width, so every variant lands on the same bin grid. Card-level range
filters on the dim still narrow the bins, since they're shared by all
variants.

- move filters->field-map + extract-bounds from the binning middleware
  into metabase.lib.binning.util (middleware delegates; extract-bounds
  now returns nil and the middleware keeps the qp.error-type throw)
- add metabase.lib.binning/default-bin-width (exported via lib.core):
  the width :default binning resolves for a column given the query's
  own filters, nicer-breakout fixed point included
- pin it in metabase.explorations.query-plan.mbql/apply-default-bucket

* Address CR

- default-bin-width now keys its filter lookup by the column ref's own
  id-or-name (like the QP binning middleware) instead of (or (:id column)
  (:name column)) with a regression test for the bug this fixed.
- Build the segment :definition test fixtures through lib instead of the
  mt/mbql-query macro
2026-08-21 14:34:57 -07:00
Cam Saul
a816155366 Clean up usages of 'pMBQL' in our documentation (#80390) 2026-08-21 21:00:00 +00:00
Kevin Kelleher
faa3018ebf Explorations toggle for contextual interestingness (#78239)
* toggle for contextual interestingness

* update toggle, language in AI usage views

* fix test
2026-08-21 20:56:19 +00:00
Anderson
3b6a98b66b Hide the empty dashcard menu on visualizer cards whose source card cannot be downloaded (#80113)
* Hide the empty dashcard menu on visualizer cards when downloads are not permitted

* Address review comments

* Type the dashcard menu's underlying-question items as an array and simplify code
2026-08-21 20:24:30 +00:00
Nemanja Glumac
d4268ac46e Remove unused merge_group CI logic (#80535) 2026-08-21 21:58:10 +02:00
Anderson
ed16a0bc37 Migrate home cards from Emotion to Mantine (#80335)
* Migrate home cards from Emotion

* Fix x-ray icon sizing
2026-08-21 16:06:16 -03:00
Nick Fitzpatrick
866b1b7bb0 add copy value click action (#79837)
* add copy value click action

* move details above filter

* move copy into it's own section

* fix import

* e2e adjustments

* Adding unit test for column settings

* Don't show copy action on PK columns
2026-08-21 15:54:06 -03:00
Sloan Sparger
f53640862e [Metabot] Handle conversation reaching max window size (#79859) 2026-08-21 18:36:21 +00:00
Chris Vandoodewaard
29afd6c0ad turn off slack messages in engineering-ci and create Linear issues instead (#80533)
* create tickets instead of sla
ck notifications

* add CI Triage label

* add pagination

* fix

* address pr review
2026-08-21 14:31:17 -04:00
Anderson
e86c35cd4d Migrate collection header caption styles from Emotion to Mantine (#80331)
Migrate CollectionCaption from Emotion to Mantine and CSS module
2026-08-21 14:21:23 -03:00
Alexander Polyankin
7e8be17997 Improve mongo value compilation (#80509)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:56:34 +00:00
Alexander Polyankin
13cddfb68d Don't splice unvalidated database-type into JSON-field casts (#80482)
h2x/cast already guards cast targets: a plain type name is emitted raw,
anything else is quoted as an identifier. The MySQL and Postgres
json-query implementations bypassed that route and spliced the stored
field's database-type into SQL unvalidated (CONVERT(.., <raw>) and
::<raw> respectively).

Expose the plain-type-name predicate as h2x/raw-type-name? and use it:
Postgres quotes a non-plain type as an identifier (same rule as
h2x/cast); MySQL rejects it with an invalid-query error, since
CONVERT's target type cannot be a quoted identifier.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:49:23 -04:00
Alexander Polyankin
0ea74a39ff Reject implicit-join field refs in tile endpoints (#80480)
Tile endpoints resolve latField/lonField legacy refs via resolve-field;
a ref with :source-field (implicit join) now fails fast with a 400
instead of producing a broken query. The check lives in resolve-field,
which every tile endpoint (ad-hoc, card, dashcard, public, embed,
preview-embed) reaches through tiles-query.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:49:22 -04:00
Alexander Polyankin
abf7c47f3c Strip internal query-processor keys at the query boundary (#80392)
Internal query-processor and permission keys (`:qp/...`, `:query-permissions/...`,
`:impersonation/...`, `:persisted-info/...`, `:source-query/...`) are added to a
query during preprocessing and drive permission enforcement, so a client must
not be able to supply them. Previously five separate stripper middlewares
removed them one key at a time.

Replace those with a single declarative rule: a qualified map key outside the
`:lib` namespace is an internal key and gets stripped when a query is
deserialized from JSON. The rule is a Malli `:decode/api` (and
`:encode/serialize`) transformer on the query/stage/join/clause-options schemas,
run via `lib.serialize/prepare-after-deserialization` at the JSON boundaries
(REST API request decode and app-DB read) and once as a preprocessing
middleware, `qp.perms/remove-internal-keys`, gated by `qp.pipeline/*pivot?*` so
pivot sub-query keys survive their re-run.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 12:49:22 -04:00
Sloan Sparger
4dbf593d23 [Metabot] Key conversation state by conversation id (#78759) 2026-08-21 11:35:01 -05:00
Alexander Polyankin
ede6a0ec56 Improve API schemas (#80332)
* Improve API schemas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keywordize nested parameter option map keys

`::parameter-with-value` only keywordizes its top-level keys (via
`normalize-map-no-kebab-case`), so on the query-string parameter path -- where
the whole value arrives as a JSON string with string keys at every level -- the
nested `parameter.options` and `dimension.options` maps kept string keys. The
api decode's `strip-extra-keys` pass then dropped those undeclared string keys,
silently emptying `:options` (a case-insensitive filter would run case
sensitively, a relative date filter would lose `:include-current`, a
stage-targeted dimension would lose `:stage-number`).

Give both option schemas a `:decode/normalize` of `lib.schema.common/normalize-map`
so their keys are keywordized before the strip pass, matching how the rest of
the lib schemas decode map keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address agent-api / xrays review feedback

- /v2/query continuation-token branch now normalizes-and-validates the embedded
  query like the query_handle branch does, instead of trusting the unsigned
  token's shape; the total-row budget is read off that validated query.
- reject-native-query! normalizes the payload to MBQL 5 (best-effort) and checks
  for a native stage with lib/any-native-stage?, so native detection no longer
  depends on how the JSON was decoded (keyword vs string keys); /v1/execute and
  the query_handle path decode with plain json/decode and let normalization do
  the keywordizing. Malformed payloads still fall through to the existing shape
  checks for their clean 400s.
- Renamed validate-query -> normalize-and-validate-query (it normalizes and
  strips, not just validates) and led its docstring with what it returns.
- xrays cell-query route params decode declaratively via a ::cell-query schema
  (:decode/api base64-decodes + validates ::root.cell-query) instead of
  base64-decoding once in the schema and again in the handler.
- Tiles reject test asserts a clean 400 (status-code) rather than any Exception.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update sandbox dimension-target test for keywordized option keys

The dimension-target `:options` map now keywordizes its keys (e.g. `"stage-number"`
-> `:stage-number`), so the attribute-remappings round-trip test expects the
normalized keyword form on the way back out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:15:18 -04:00
Brad Anderson
a4eb75df3a GRO-829 Commit embedding docs (#80186) 2026-08-21 11:14:08 -04:00
Nicolò Pretto
9ad311d127 attempt to cut admin <-> sdk cycle in modules (#80146) 2026-08-21 16:54:34 +02:00
Chris Vandoodewaard
7abb55964b display CI Conductor link for failing tests (#79601)
* add permalink to quarantine entry, update compare function to return QuarantineEntry type

* print search url to make tests easy to find

* update tests

* remove a few comments

* simplify search url
2026-08-21 10:31:14 -04:00
Egor Grushin
a28a9af735 "Send now" in the question alert modal is never disabled (#80439)
Disable "Send now" when the alert has no delivery target (#79881)

Extract alertHasValidTarget from alertIsValid: the handler and channel
checks without the subscriptions one. Send now is a one-off send that
ignores the schedule, so it gates on the target only, while Done keeps
the full check.
2026-08-21 14:44:56 +01:00
Egor Grushin
24fa76cfa8 Fix the disabled Done button in the question alert modal (#80438)
Fix disabled Done button in question alert modal (#79914)

bg is a style prop, lands inline on the root and beats the :disabled
rule in Button.module.css, so the button kept its brand background while
the label switched to the dark text-disabled token. color feeds
--button-bg instead, which the disabled rule overrides.
2026-08-21 14:27:28 +01:00
Nicola Mometto
dad470d197 Add validation and permission checks for content endpoints (#80277)
* Run full query-permission checks when compiling a native form (SEC-822)

POST /api/dataset/native compiled and returned the query after checking only
database-level native perms, so a referenced source card or card template tag
was resolved without a read check. Preprocess the query once (with inline
parameter compilation enabled), run the same check-query-permissions* checks
the run path uses (surfacing a permission failure as a 403), then compile the
preprocessed query. Also trim the response to :query and :params.

* Validate link-card entity ids on write for dashboard cards (SEC-836, SEC-832)

DashboardCard validated the link-card entity id only when building the read
query; nothing checked it on write, so a non-integer id could be stored and
would then break every hydration of the dashboard. Add a write-side check in
define-before-insert and a new define-before-update, and wrap the notification
link-card renderer in try/catch so one bad card degrades to a missing part
instead of failing the whole subscription. Adds regression tests.

* Require positive-integer entity ids in dependency backfill (SEC-830)

replace-dependencies! placed to_entity_id directly in the insert, and the
dashboard click_behavior extraction forwarded targetId with no type check. Add
an explicit pos-int? check at the insert where to-add is built (a runtime
check, not an mu/defn schema, which is stripped from prod builds), and filter
targetId to positive integers during extraction so bad ids drop rather than
making the backfill job throw. Adds regression tests.

* Validate click-behavior target ids on write for dashboard cards (SEC-830)

Review follow-up: the entity ids the dependency backfill consumes come from
dashcard click_behavior targetIds, so validate them at the write boundary as
well, keeping the deep guard in replace-dependencies! as defense in depth.
Covers both the top-level click_behavior and per-column column_settings ones,
on insert and update. Adds regression tests.
2026-08-21 08:32:38 -04:00
Romeo Van Snick
72b3d9c271 Drop the two selector-level display readers of the metadata mirror (#80400) 2026-08-21 21:21:10 +10:00
Nicolò Pretto
be612d2dc6 support passing multiple fonts in embedding (#80442) 2026-08-21 13:15:28 +02:00
github-automation-metabase
3d99e1332f Wait for query run before asserting native-conversion results (#78274)
The 'converting question to a native query' e2e test asserted on
[data-testid=cell-data] immediately after clicking 'Convert this
question to a native query' (and after 'Explore results'), without
waiting for the POST /api/dataset run to settle. Under load the
assertion raced a stale/mid-flight results table and timed out.

Mirror the working convertToSql() helper: intercept POST /api/dataset
and cy.wait for it before each cell-data assertion.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-21 13:05:29 +02:00
Kevin Kelleher
5e5b740287 Fix commenting on a chart without a timeline selected in explorations (#80378)
don't send null timeline_id to the BE in a comment's context
2026-08-21 07:00:22 -04:00
Kevin Kelleher
57ca3a007d Add row count check to column cardinality cache (#80176)
add row count check to column cardinality cache
2026-08-21 07:00:00 -04:00
Romeo Van Snick
a5e4adb11a Move plan, frame-paint, and export-format helpers out of common (#80049)
* Move plan util from common into lib utils

* Move waitUntilNextFramePainted from common into lib utils

* Move export format types from common into metabase-types

* Move plan helpers into the settings module
2026-08-21 20:32:45 +10:00
YB
866c840355 gate pulse test-send behind the subscription application permission (#80136) 2026-08-21 18:32:13 +08:00
YB
d914e47f76 escape parameter & heading text when rendering notification emails (#80055)
escape parameter and heading text when rendering notification emails
2026-08-21 18:31:57 +08:00
Andrei Simionescu
537b242202 PRO-180: Add copy to clipboard for query results (#79341)
Add a copy button to the query builder's view footer, next to download.
It copies whatever is on screen: tabular results (tables, pivot grids,
or a chart toggled to the raw data view) as tab-separated text plus a
text/html table flavor so spreadsheets paste a grid, and any other
chart as a png rendered through the same pipeline as the png download.

Copies are always formatted, matching the rendered table: remapped
columns, column visibility and order, renamed titles, click-behavior
link text, and pivot grids with their headers and totals. Raw data
stays a download concern. Copying is capped at the rows loaded in the
browser, with a toast saying when only the first slice was copied.

The clipboard write starts inside the click's user activation so Safari
accepts it, cell click metadata is built only for columns whose text
needs it so wide tables serialize in linear time, and the selected-cell
copy now shares the quoting TSV serializer.
2026-08-21 10:31:18 +01:00
Ngoc Khuat
118ee5715c Cover database detail handling on serialization import (#80334)
Cover Database connection details on serialization import

Pins the two checks in load-one! for Database: an H2 engine is refused, and
low-level JDBC keys are dropped from details, write_data_details and
admin_details.
2026-08-21 16:13:09 +07:00
Chris Truter
5f4bb206a1 Treat a 403 from construct_notebook_query as agent-facing (#79983) 2026-08-21 10:36:06 +02:00
Ngoc Khuat
a6345c149b Quote table names in the MySQL rename, and cover transform target names (#80068) 2026-08-21 08:26:26 +00:00
Nicola Mometto
992d8c36e2 Add checks to document card-creation and AST reference ids (#80145)
* Harden document card-creation permission checks

Route document create/update/copy card creation through the same checks
POST /api/card applies: create-check the target collection, read-check
each card copied by document_id FK, and query-permission-check the fields
that parameter targets name.

* Guard document AST entity ids before app-DB lookups

smartLink/cardEmbed reference ids come from free-form document JSON and
may be any value. Add a shared node-entity-id accessor that yields the id
only when it is a positive integer, and use it in the raw-entity readers
(serdes export transform, export-time dependencies, and descendants) so a
malformed reference is skipped rather than passed to a Toucan query.
2026-08-21 09:23:57 +01:00
Nicola Mometto
024aa144a0 Add access and visibility checks to comments and mentions (#80142)
* Add access and type checks to comment notifications

Mentioned users are now resolved to active users whose permissions cover
reading the commented entity before being added to the notification
recipients, matching how other delivery surfaces scope their audiences.

The TipTap text-node renderer now coerces non-string :text values to
strings so they always compile as escaped text rather than as hiccup
elements.

* Scope the comment mentions listing to visible users

GET /api/comment/mentions now applies the same audience rules as
GET /api/user/recipients: non-superusers only see users in their own
tenant, further narrowed by the user-visibility setting (:none -> self,
:group -> shared-group members, :all -> everyone in the tenant). This
also resolves the endpoint's long-standing TODO about tenant handling.

same-groups-user-ids moves from the user REST namespace into the user
model so both endpoints share one implementation.
2026-08-21 09:23:37 +01:00
Nemanja Glumac
3fea6bf3fc Fix the action creator modal flake (#80282) 2026-08-21 10:11:07 +02:00
Fraser
79f3813c3e Tolerate stale self entries in the side-effect registry spec (#80401) 2026-08-21 20:10:50 +12:00
Fraser
5516e40607 Add extension points in viz for sdk behaviour (#79436) 2026-08-21 16:23:06 +12:00
Romeo Van Snick
ebe7663ba3 Read the query's database from the API, not the entities mirror (#80267)
Read the query's database from the API in the three feature-check consumers
2026-08-21 14:09:18 +10:00
Romeo Van Snick
234c695a25 Dissolve the lib-tier type cycle between metabase-types, metabase-lib, and utils (#80053)
* Move JoinConditionOperator from metabase-lib into metabase-types

* Move font family and DeepPartial types into metabase-types

* Generate mock document ids without the lib utils uuid helper

* Import JoinConditionOperator from the api barrel in metabase-lib

* Drop lib-utils re-export shims and inline docs at the moved declarations
2026-08-21 12:26:21 +10:00
Fraser
e247190827 Make metabase/ui side-effect free (#79962) 2026-08-21 14:17:50 +12:00
Tyler Plude
8661fd4524 [BOT-1930] Self hosted vLLM metabot support (#79437)
* init vllm support

* tmp improvements

* cleanups; bump max tokens floor for vllm; env shadowed cred field guards

* fix openrouter temperature

* vllm reasoning

* fixes

* cleanups

* make vllm transport errors non-retryable

* fix vllm connect diagnostics; add recorded stream fixtures

* [BOT-1930] Rebuild vLLM support on the provider connection registry (#80103)

* Rebuild vLLM support on the provider connection registry

vLLM becomes a provider type in `metabase.llm.provider` rather than a set of
`llm-vllm-*` settings read by the adapter: a required base URL and an optional
API key, no default model, and an `MB_LLM_VLLM_API_BASE_URL` group so the
environment can configure one connection. The base URL is the credential — a
server started without `--api-key` takes no key — so it alone brings a usable
connection into existence, and the admin form renders from the descriptor
instead of a bespoke component.

Two things the adapter used to reach out for now travel with the connection:

- The connect-time preflight, which exercises tool calling and structured
  output against the model that will be used, runs from `verify-credentials!`
  under a new `probe?` flag. Only writes probe; a model listing must not spend
  a generation on the operator's server.

- Whether the served model streams reasoning, which only the probe can answer,
  is reported back as `:learned-config` and stored on the connection instead of
  in a global setting, so two vLLM connections no longer overwrite each other's
  verdict. The request path reads it from the credentials it is handed.

A vLLM connect has no default model to select, so it adopts the model its probe
exercised, and an edit re-probes against the model the connection is actually
serving Metabot.

* Document connecting a vLLM server

The provider list in the AI settings docs is hand-maintained. vLLM is the one
provider an admin can misconfigure outside Metabase, so it gets the server flags
the connect-time check looks for alongside its entry.

* fix kondo

* revert docs

---------

Co-authored-by: Tyler Plude <tsplude@gmail.com>

* review comments

---------

Co-authored-by: Maksym Yakubych <maksym@metabase.com>
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
2026-08-20 22:00:58 -04:00
Fraser
e7a84b1625 Fix Mongo native preview sidebar assertions after default-limit removal (#80395) 2026-08-21 13:39:57 +12:00
github-automation-metabase
449e3f5c76 Auto-update documentation for master (#80376)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-20 21:18:29 +00:00
Kevin Kelleher
ce55f0cdc6 Fix axis labels for timezone naive strings (#80121)
* fix axis labels for timezone naive strings

* Update Loki Snapshots

* fix lint after rebase

* don't throw in tests

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-20 17:11:25 -04:00
Denis Berezin
706c3862ab UXW-4718 Dashboard tab selection not persisted on page refresh (#79669)
* UXW-4718 Dashboard tab selection not persisted on page refresh

* review fixes

* Fix unit tests

* Fix unit tests
2026-08-20 23:32:07 +03:00
Anderson
723aebae08 Aggregate region and grid map tooltips when the mapped dimensions are not unique (#80027)
* Aggregate map rows sharing a region or grid cell and warn about unaggregated data

* Fix comment
2026-08-20 17:19:59 -03:00
Ben Grabow
06f7a3ca8b Bgrabow/profile tests workflow (#80258)
* Add workflow dispatch/profiling options to app-db, backend, semantic-search workflows
2026-08-20 20:12:09 +00:00
Chris Vandoodewaard
e30fb0cec1 report all test results on rerun to CI Conductor (#80326)
* upload all pass/failures for backend tests on rerun

* also for frontend tests

* add some tests

* cleanup whitespace

* copilot review

* a few more fixes from local review

* determine rerun from github context instead of input
2026-08-20 14:50:15 -04:00
Ben Grabow
9add75e67f Use content-based cache keys for m2, bun, cypress, eslint. (#80219)
* Use content-based cache keys for m2, bun, cypress, eslint.
Eagerly populate cache on push to master/release for m2, bun, cypress.
Read-write eslint cache from every branch.

* Handle multiline outputs

* Remove unneeded M2 restore on prepare-frontend

* Handle potentially multiple cyprss version matches. Update commentary.

* Validate list of alias combinations in use across the repo

* Check preresolve aliases in parallel
2026-08-20 12:38:42 -05:00
github-automation-metabase
af4fd281b4 Auto-update documentation for master (#80250)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-20 13:27:35 -04:00
Nicolò Pretto
6f36c446e6 remove --no-package-lock from embedding host app tests (#80094)
remove --no-package-lock
2026-08-20 18:30:08 +02:00
lbrdnk
c010ae765a Reuse workflow to dockerize an image in perf test workflow (#80205)
* Reuse dockerize workflow

* Remove uberjar job from labeled workflow
2026-08-20 18:17:55 +02:00
appleby
b6edd4379c Enable Qwen 3.8 Max via OpenRouter (#79735)
* Enable Qwen 3.8 Max via OpenRouter

* Downgrade tool_choice "required" to "auto" for qwen3.8-max

Alibaba returns a 400 request error if you pass tool_choice "required" and unlike moonshot / Kimi K3, does not allow
you to disable thinking mode for Qwen 3.8.

* Add coerce-stringified-scalars to transform tool call args

Qwen 3.8 sometimes sends integer args as strings, like `{:limit "15"}`. Use the tool's declared schema to
automatically transform any simple numeric or boolean scalars.
2026-08-20 08:29:53 -07:00
Timofey Kachalov
7e3d167c05 Adjust skills to instruct using default Question components when possible (#80248) 2026-08-20 19:24:21 +04:00
Nicola Mometto
ef87a8221e Guard transform revision history per snapshot (#80088)
* Test: transform revision diffs should not include past source bodies

A transform's revision history diffs two full snapshots, so every past
:source (native SQL / Python body) and :source_database_id shows up in
the diff - even for versions that read from a database the current
viewer has no query access to. Add a test asserting the diff only
describes that the source changed, without reproducing the old source.

* Guard transform revision history per snapshot, not per current row

* extra guards in metabot tool
2026-08-20 15:16:56 +01:00
Timofey Kachalov
4c5abeb77e Add description field for data apps (#79544) 2026-08-20 17:57:34 +04:00
Sloan Sparger
fdc8caffe7 [Metabot] Handle tool-calls finish reason (#79860) 2026-08-20 14:53:51 +01:00
Stas Gavrylov
62c29ab1eb GDGT-2932: Refactor AI Analytics views to use internal routes and shared layout (#79010)
* GDGT-2932: Refactor MCP/CLI analytics to shared-layout + nested routes

Replaces the tab-query-param pages with a shared layout (header, tab
navigation, filter bar, and a single "has any data" gate) rendering
usage/events sub-routes through an Outlet, mirroring the
dependency-diagnostics pattern. New URLs:
- /monitor/ai-auditing/mcp/{usage,events}
- /monitor/ai-auditing/cli/{usage,calls}
with an index redirect from the bare section path to /usage.

Shared filter/pagination state is passed to the active leaf route via a
typed React context (following MonitorContent's existing
useMonitorSidebarContext precedent) rather than react-router's untyped
useOutletContext.

Also fixes two bugs found in cross-review: tab links were dropping the
current query string (clearing filters on every tab switch, since
LinkTab doesn't inherit location.search), and the index redirect was
gated behind the "has data" query so a bare /mcp or /cli with zero rows
never canonicalized to /usage. Bundles a parity fix for useCliHasData
(now surfaces query errors, matching useMcpHasData) so the CLI usage
view doesn't spin forever on a failed count query.

* Fix MonitorHeaderTabs to use aria-current instead of the ARIA tabs pattern

MonitorHeaderTabs rendered its options with role="tab" (via Mantine
Tabs.Tab + a Link component), but each "tab" is a real, bookmarkable
route rather than in-place content switching — the ARIA tabs pattern
implies activating a tab doesn't navigate, which isn't true here. It
also never rendered a matching tabpanel/aria-controls pairing, since
the actual content lives in a react-router <Outlet/> outside the Tabs
tree entirely.

Rewritten as plain nav links with aria-current="page" on the active
one, matching the existing AreaTab component's approach for the same
kind of route-driven navigation. Visual pill styling is preserved via
a small CSS module. Updates the two dependency-diagnostics/tasks/mcp/
cli consumers' tests and e2e specs that asserted the old role="tab".

* GDGT-2932: Review

* GDGT-2932: Keep error count queries mounted across analytics routes

* GDGT-2932: Cleanup

* Fix task tabs E2E selector

* GDGT-2932: Address PR comments

* GDGT-2932: Cleanup

* GDGT-2932: Remove LinkTab. Introduce shared AiAnalyticsSectionLayout. Fix comments
2026-08-20 16:21:00 +03:00
Stas Gavrylov
f78ab5b83f Upgrade Scalar API Reference to 1.65.1 (#80289) 2026-08-20 15:54:15 +03:00
Fraser
7cedc004a8 Fix the module side-effect lint errors in querying/expressions (#80285) 2026-08-20 23:35:31 +12:00
Fraser
d2c5d44b63 Add a lint rule that keeps side-effect-free modules side-effect free (#80047) 2026-08-20 22:22:11 +12:00
Nemanja Glumac
ee92e5d470 Reproduce 23103 using Jest instead of e2e (#80237) 2026-08-20 11:01:14 +02:00
Nicola Mometto
f0bf525611 Require write permission to send notifications and to use inline channels (#80069) 2026-08-20 09:57:40 +01:00
Romeo Van Snick
d7e2aa3d5b Check database features through the API helper, not the v1 wrapper (#80158)
* Check database features through the API helper, not the v1 wrapper

* Move the database feature checks out of common, behind a databases barrel
2026-08-20 15:46:50 +10:00
Romeo Van Snick
da126bb88c Give the remaining app routes their own chunks (#80160)
* Give the remaining app routes their own chunks

* Fetch the home page chunk while the user signs in
2026-08-20 13:54:35 +10:00
Romeo Van Snick
4711e0c845 Always invalidate remote sync on card, dashboard and document mutations (#80148) 2026-08-20 13:54:19 +10:00
appleby
6d22e0c553 Add deepseek-v4-pro-0813 to openrouter supported-models (#80018) 2026-08-19 16:55:48 -07:00
Sloan Sparger
8ed4b9d8dc [Metabot] Reasoning style updates (#80127) 2026-08-19 22:30:28 +00:00
Romeo Van Snick
f5bdbf045d Load the dependency graph and schema viewer pages lazily (#80050)
* Move the xyflow stylesheet into the graph components

* Give the schema viewer one loader for its route and its prefetch

* Load the dependency graph from the plugin slot lazily
2026-08-20 08:22:14 +10:00
bryan
9450affe98 Sign session key hashes with a server-side secret (#80046) 2026-08-19 22:04:09 +00:00
Maksym Yakubych
25ed6e5a58 Update the OpenRouter provider logo to the refreshed brand mark (#80253) 2026-08-19 21:50:55 +00:00
Maksym Yakubych
84be938a6e fix(select): don't render option groups emptied by search (#80197) 2026-08-19 21:46:36 +00:00
Maksym Yakubych
909156c65d Deflake metabot error boundary test by loading the chat module at suite setup (#80213)
* Deflake metabot error boundary test by loading the chat module at suite setup

* Preload the chat module in the shared metabot test setup

* Use the shared metabot test setup in the error boundary spec
2026-08-19 20:56:53 +00:00
Nathan Voxland
9a0e229370 Better tracking of attached data warehouse connections (#79910) 2026-08-19 15:07:30 -05:00
Andrei Chernykh
68746a6d43 Clean up leftover JS-era config, deps, and docs after the TypeScript migration (#80032)
* Clean up leftover JS-era config, deps, and docs after the TypeScript migration

* Address review: keep .jsx in resolve.extensions, drop .js from loader tests, includes-based e2e validator filter

* Keep @babel/node for a separate deps cleanup round

* Keep js/jsx in rspack loader tests
2026-08-19 12:48:52 -07:00
github-automation-metabase
dc81dff870 Fix flaky dashboard parameter-removal e2e test (#78302)
* Fix flaky dashboard parameter-removal e2e test

The "should be able to remove parameter (metabase#17933)" test asserted on
the full dashboard URL query string with cy.location("search").should("eq",
...). The order in which parameter slugs are serialized into the URL is not
deterministic, so a correct-values-but-different-order string
(?text_starts_with=G&text_ends_with= vs ?text_ends_with=&text_starts_with=G)
could never match and the retry just spun until timeout. Empty/stale-URL
timing races produced the other observed failures.

Replace the four exact-string assertions with an order-independent helper that
parses the query string via URLSearchParams and deep-equals the expected
param map. It still retries through cy .should(callback), so async URL updates
settle before the assertion resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Wait for parameter-remove button to enable before clicking

The parameter-removal step in the dashboard parameters spec clicked the
sidebar's Remove control via cy.findByText("Remove"). That query resolves to
the button's inner label <span>, not the <button> element. A span is never
"disabled", so Cypress's actionability check did not wait: it clicked the span
while the underlying button was still disabled.

The Remove button stays disabled until the dashboard's subscriptions have
loaded (the sidebar warns before archiving any subscription bound to the
filter). When that GET /api/pulse request was slow, the click landed on the
disabled button as a silent no-op, the parameter was never removed, and the
subsequent URL assertion timed out waiting for the slug to disappear.

Target the button element by role and assert it is enabled before clicking so
the click waits for the subscriptions request to settle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix linting error

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
2026-08-19 21:30:30 +02:00
github-automation-metabase
8107c34e1d Auto-update documentation for master (#80120)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-19 15:22:08 -04:00
Nick Fitzpatrick
97ecbeab61 Update auto 'Show Dots' logic (#80071)
* Update auto 'Show Dots' logic

* Update Loki Snapshots

* PR feedback

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-19 16:09:30 -03:00
github-automation-metabase
3ea4987f1f Fix flaky revision-history dashboard test (#78280)
The test opened the info sidebar right after an ad-hoc edit-mode save that
only awaited the PUT /api/dashboard request. The trailing query_metadata
reload re-rendered the header, dropping the 'More info' click so the
sidesheet never opened. Use the H.saveDashboard() helper, which waits for the
full save-and-settle sequence before proceeding.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 21:07:10 +02:00
bryan
58c85c338c Only serialize Channel :details for callers who can write the channel (#80057) 2026-08-19 13:00:45 -06:00
github-automation-metabase
d056853a8c Fix flaky segment_created tracking e2e test (#78610)
* Fix getTable intercept in segment_created tracking e2e test

The segment editor's DataStep loads a table via fetchTableMetadataAndForeignKeys, which calls GET /api/table/:id/query_metadata. The test intercepted the bare GET /api/table/:id endpoint instead, so cy.wait("@getTable") always timed out because that request never fires in this flow. Point the intercept at the query_metadata endpoint the editor actually calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Gate segment filter click on enabled Add-filters button

The segment editor renders a disabled "Add filters to narrow your answer"
button until onChange builds the query from fully-loaded table + FK metadata.
Waiting on the getTable response alone isn't enough — the trailing foreign-key
metadata loads race the click, so the click lands on the still-disabled button
and the filter popover never opens. Assert the button is enabled first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 20:55:18 +02:00
Nemanja Glumac
368c50f7e4 Explicitly exclude old-migrations-test tag (#80214) 2026-08-19 20:50:00 +02:00
Chris Vandoodewaard
3c70c47538 Revert "only publish Github release for major and gold releases" (#80220)
Revert "only publish Github release for major and gold releases (#79478)"

This reverts commit 814b57b74a.
2026-08-19 18:45:08 +00:00
Nick Fitzpatrick
b872cccdde Uxw 5027 model metadata editor UI issues (#79909)
* everything except model index

* use-should-index

* remove 0.88em form label
2026-08-19 14:38:17 -03:00
Stas Gavrylov
0c5d3b3a59 GDGT-2459: Update Tooltip component (#79994)
* GDGT-2459 Update Tooltip styles

* Update Loki Snapshots

* Add Tooltip Overview Loki reference

* GDGT-2459 Add Tooltip spacing and shadow

* GDGT-2459 Use standard small Tooltip shadow

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-19 20:19:40 +03:00
Chris Truter
3989cb5eb3 Standardize nonparallel tests on ^:synchronized (#80064) 2026-08-19 17:15:36 +00:00
Chris Vandoodewaard
83adb8f686 make cross version tests run using Postgres (#79091)
* add sample db

* delete cross version 57 since we don't support it anymore

* update cross version tests to use postgres

* fix modal selector to work on v1.60

* fix modal selector for v60 and v63 compatibility

* add better logging

* use qa image

* use herestring to handle large output properly

* use data from WA image

* fix flake in nested model assertion

* cleanup the README

* code review fixes
2026-08-19 13:11:33 -04:00
Nick Fitzpatrick
3e9460c53e Uxw 4977 show collection that couldnt be synced (#79652)
* updating remote sync enpoint error response

* adding FE types

* Show required dependencies on sync enable

* modal version

* tests

* maz feedback

* Handle Our Analytics case

* Code review fixes

* Fixed bad fix

* Rebase and cleanup

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-19 13:38:03 -03:00
Chris Truter
6327ba0828 Stop LLM impersonation tests leaking cached field values (#80178) 2026-08-19 18:03:07 +02:00
Anderson
6b4af53319 Keep metric dimension rows full-width by placing the vertical scrollbar in the column gutter (#80008)
Keep metric dimension rows full-width by moving scrollbar into column gutter
2026-08-19 13:00:28 -03:00
Eric Normand
6bd66a6d5e Validate imported queries against this instance's MBQL schema (GHY-4241) (#79496)
* Validate imported queries against this instance's MBQL schema (GHY-4241)

An export decides the shape of the queries it carries, so an import can hand us
a query shape this version has no representation for. `lib/normalize` does not
always reject one: it can quietly produce something the schema rejects, which
is then stored and breaks the card on read.

After `import-mbql` normalizes an ingested query, validate the result against
`::lib.schema/query` and refuse the import if it fails. Only full queries are
checked - bare refs and the MBQL fragments inside visualization settings have no
standalone schema to validate against.

Normalization failures stay non-fatal. They fire today on content this instance
produced itself (pivot column refs in visualization settings raise
`:malli.core/invalid-schema`), so promoting them would reject legitimate
imports. Validating the successfully-normalized result catches the real problem
without that fallout.

`MB_SERIALIZATION_SKIP_SCHEMA_VALIDATION` opts out. `metabase.models.serialization`
cannot read an EE setting, so the flag reaches it through a dynamic var bound by
`load-metabase!` - the one choke point every import path funnels through.

Also validate the bundled instance analytics content against the same schema in
CI. That content re-imports on every EE boot and a failure there propagates out
of `init!` to `System/exit`, so a bundle regeneration that broke it would
crash-loop rather than degrade.

* Fix import validation documentation

* Refactor documentation for import validation settings

* update docs

* Report the schema refusal as a 400 and stop duplicating its errors (GHY-4241)

The serdes import API reads `:status` off ex-data itself rather than going
through the API layer's exception handling, so `:status-code` left it nil and
the refusal surfaced as a 500 for what is a client input error.

`u/strip-error` already renders ex-data into the message, so the truncated
`Schema errors:` tail was a second, shorter copy of `:schema-errors`.

* Drop the unreachable status code from the schema refusal (GHY-4241)

`load-one!` rewraps every exception from the load block in a fresh ex-info
carrying `path-error-data`, and the import API reads `:status` off the
outermost exception only. Neither `:status` nor `:status-code` attached here
was ever visible, so claiming one in a comment was worse than saying nothing.

* Name both causes in the refusal, and pin bundle ingest errors (GHY-4241)

`mu/defn` schemas are not instrumented in prod, so an app DB can hold MBQL the
QP tolerates but `::lib.schema/query` rejects - a stale `:join-alias`, a field
filter with no `:widget-type`. Such content exports intact and is then refused
on import, so "most likely exported by a newer Metabase" was the wrong
diagnosis for a whole class of refusals. Name both causes, and point at
continue_on_error as the per-entity escape rather than only the global one.

The bundle test proved every query imports but not that every file parses.
`ingest-list` collects unreadable files in an errors atom rather than failing,
so a malformed bundled YAML would drop out of the queries under test while
still crashing EE boot. Assert the atom is empty too.

* Move refusal rationale out of docstrings, harden a test helper (GHY-4241)

Docstrings state the contract; the reasoning behind the check, the message
wording and the sentinel moves to comments beside the code it explains.
`*skip-schema-validation?*` no longer names who binds it, which would go stale
silently. `validate-imported-query!` now says legacy MBQL 4 queries are left
alone as well, which was true but unstated.

`cause-chain-messages` used `map ex-message`, so an exception with no message
would put a nil in the vector and NPE the callers' `re-find` - failing the
test for a reason unrelated to what it asserts.

* kick ci

* Simplify the import schema check's control flow (GHY-4241)

`validate-imported-query!` returns its argument and no longer consults the
opt-out, so it is a plain step in `import-mbql`'s pipeline and the caller
decides whether to run it. That removes the sentinel `normalize-imported`
needed to keep the refusal outside its `catch`, along with the comment
explaining the sentinel.

`some->` replaces `->`, which makes `normalize-imported`'s nil guard
redundant.

* update comment
2026-08-19 15:38:28 +00:00
Michael Eby
50568b4841 docs: remove db secrets export from serialization docs (#80114) 2026-08-19 11:20:28 -04:00
Chris Vandoodewaard
272b17ffdd [Flaky Test]: should send snowplow events through admin settings (#80005)
* don't assert on toast

* dismiss toast after assertion
2026-08-19 10:57:24 -04:00
Ben Grabow
64b5ba8500 Fix flaky tests: Error rolling back after previous error: Deadlock detected. (#80126)
Retry on search reindex deadlock
2026-08-19 09:42:02 -05:00
Chris Truter
285d779b78 Use the in-process embedder for retrieval and complexity score (#78314)
* Route semantic embeddings through the provider SPI

Summary

- Register the existing ai-service, OpenAI, and Ollama implementations through the shared provider registry.
- Route semantic embedding, readiness, resolution, and preparation through the SPI while preserving the existing public adapter.
- Preserve the compatibility client model-dimensions contract by normalizing it to vector-dimensions at the provider boundary.
- Keep semantic-specific batching, metrics, and settings policy in semantic search.

Scope

This migrates consumers to the SPI. Persisted embedding-space compatibility and in-process provider selection are intentionally handled by later stack layers.

Testing

- Built-in provider behavior and existing semantic embedding adapter tests.
- Compatibility-client dimension normalization and conflict handling.

* Describe the embeddings client contract in SPI terms

The batch entry point delegates to the provider registry rather than a multimethod.

* Port complexity-test fake embedder to the provider SPI

get-embeddings-batch is a plain fn now, so the old defmethod no longer compiles
and broke every job that loads this namespace. Register a capturing provider
instead and assert on the descriptor it receives.

* Induce the dimension mismatch from the model, not the mock vector

The SPI rejects a vector whose length doesn't match the resolved model, so a dim-1 mock embedding
no longer reaches pgvector. Shrink the configured model instead, which is what an upgrade does.

* Persist immutable embedding-space identity

Summary

- Persist embedding-space IDs in semantic and library-entity index metadata.
- Backfill semantic metadata through a preserving schema migration and rebuild legacy entity vectors once when identity is unknown.
- Include immutable space identity in compatibility checks and physical table names.
- Enforce the PostgreSQL 63-byte identifier limit using UTF-8 byte length.

Scope

This establishes durable compatibility. Synchronization between entity compatibility checks and rebuilds follows in the next stack layer.

Testing

- Semantic migration preservation and backfill.
- Entity-index rebuild and compatibility behavior.
- Embedding-space table naming and non-ASCII identifier limits.

* Resolve the model in the index-cleanup test helper

record-new-index-table! now persists embedding_space_id into a NOT NULL column,
so create-test-index must build a resolved model that carries one.

* Track the index-status rename after rebase onto master

* Resolve the model-v2 variant in the reconcile tests

A bare assoc keeps the v1 embedding-space-id on a renamed model, so the rebuild wrote a meta row
whose space no longer matched what the provider resolves and nothing repopulated.

* Serialize entity index queries with rebuilds

Summary

- Coordinate library searches and reconciles with shared and exclusive PostgreSQL advisory locks.
- Keep compatibility rechecks and vector queries on the same locked connection so they cannot straddle a rebuild.
- Use non-blocking search lock acquisition so waiting searches cannot exhaust the pgvector pool.
- Preflight compatibility before embedding and return no results while a reconcile owns the index.

Scope

This PR only addresses library entity-index concurrency and degradation behavior.

Testing

- Search call ordering and locked connection reuse.
- Incompatible-index preflight and reconcile-contention degradation.

* cljfmt entity-retrieval core-test

* Follow the search-unfiltered rename in the entity-retrieval tests

Rebase fallout: the parent branch renamed `search` to `search-unfiltered`, and these call sites came across from
before the rename.

* Enable the in-process embedding provider

Summary

- Allow plugin-defined providers in semantic settings and safely encode provider names in physical index identifiers.
- Enable the bundled in-process Arctic provider with dimension readiness, query prefixing, and token metrics.
- Document the optional plugin and its glibc 2.34+ Linux or Apple Silicon macOS runtime requirement.
- Build the embedder jar in CI and run one artifact-only smoke path through the provider SPI on the OSS classpath.

Scope

This intentionally supports one pinned Arctic model. Custom model sources, MiniLM, and library overrides remain out of scope.

Testing

- Settings, prefix, token-accounting, readiness, and derived table-name regressions.
- Jar-manifest discovery, platform failure modes, batching, no-network behavior, and real ONNX inference.

* Re-hash qualified index table names over the 63-byte limit

qualify-index wraps the model table name with the metadata qualifier but did not
re-check Postgres's 63-byte identifier limit. A longer provider-encoded name plus
the qualifier could exceed it, so Postgres truncated the name on CREATE while
table-exists? still looked it up by the full name -- reporting the index table as
missing. Re-apply hash-identifier-if-exceeds-pg-limit to the qualified name.

* Hash only the table component of a qualified index name

Postgres's 63-byte limit is per identifier. Hashing the whole schema-qualified
name dropped the schema, so in app-db mode a long semantic_search.<table> became
a bare index_<sha1> created outside the isolated schema. Split off the schema and
hash only the table component.

* Cover in-process MiniLM complexity scoring

* Activate the in-process embedding provider on demand

* Stub lazy provider activation in token usage test

* Update Kondo ratchets for embedder changes

* Rely on bootstrap for in-process provider registration

* Cover the bundled complexity-score model in the artifact smoke test

The artifact job only exercised the retrieval model, so a broken or missing MiniLM
bundle -- which uses a different pooling and the three-input INT8 graph -- would have
shipped unnoticed.

* Document both bundled embedder models

* Register the in-process provider in standalone CLI mode

Provider registration happens in metabase.core.core/init!, which `--mode complexity-score`
skips entirely. A synonym axis configured for the in-process provider therefore failed with
:provider-not-registered on the CLI while working on the server.

The activation moves into metabase.embeddings.startup so both entry points run the same
routine instead of drifting apart.

* Assert the descriptor the in-process synonym embedder sends

The test claimed the embedder passes the MiniLM descriptor to the provider SPI but only
checked that it was a function, so an Arctic descriptor would have passed too.

* Pin the CLI's provider registration with a test

Nothing caught the standalone CLI skipping provider registration the first time, so assert
appdb mode registers before it scores.

* Honour :snowplow? false in the ai-service adapter

Routing single embeddings through the batch SPI dropped the option, so the health and
circuit-recovery probes started reporting their synthetic embeddings as organic token usage.

* Make the dimension-mismatch test reach pgvector

Shrinking the model with assoc kept the old embedding-space id, so resolution failed and the
query never ran -- the same path search-degrades-when-configured-model-cannot-be-resolved-test
already covers. Use a self-consistent model, stub the compatibility preflight aside, and assert
the warning that only the 22000 branch emits.

* Use dynamic redefs for the mock index's table suffix

Pre-existing on master: a plain defn redefined on one thread, so nothing here needs the root
swap. Same mock-index value either way.

* Document the tiny bundled models as a test tier

* Fix index-health-check false degraded report

row->index's :embedding-model now carries :embedding-space-id and
:embedding-spi-version, which get-configured-embedding-model doesn't
return, so the equality check in index-health-check always failed and
reported every healthy index as degraded. Compare only the keys
settings actually configure.

* Give v1 Arctic-embed models their own query prefix

model-family-query-prefixes matched every snowflake-arctic-embed
model name and prefixed queries with "query: ", which is only correct
from v1.5 onward. v1 models (including the bundled xs test-tier
model) expect the longer CQE-style instruction instead.

* Carry the plugin-name pin into embeddings.startup

The provider registration this branch moved into
embeddings.startup/ensure-in-process-provider! re-duplicated the
manifest name as its own private literal. Make it the one public
constant both the startup logic and the tests reference, instead of
reintroducing a third copy.

* Correct the Arctic Embed query-prefix cutover to v2.0

The generation split is at v2.0, not v1.5. m-v1.5 was getting "query: "
when it wants the longer instruction, and m-long matched neither pattern
so it got no prefix at all.

Replace the two mutually-exclusive patterns with an ordered first-match-wins
table. That drops the exclusivity constraint the previous shape needed and
covers every model in the family.

* Stop the query-prefix setting claiming `query: ` family-wide

The description predates the generation split and tells admins the whole
snowflake-arctic-embed family expects `query: `, which is wrong for the
bundled model.

* Anchor the Arctic Embed patterns to the model-name segment

Unanchored, they matched any name merely containing the family name, and
a false positive there is unsuppressable: `ee-embedding-query-prefix` can
replace a matched prefix but never remove one.

* Compare embedding spaces in the semantic health check

Settings can stay identical while a provider upgrade resolves the same
model into a new space, stranding the index. The space id is what
actually decides whether the stored vectors are still usable, so compare
that rather than provider/model/dimensions.

Resolution reaches the provider, so it can fail outright when the plugin
is absent or the platform is unsupported. That is its own incident, not
a model mismatch.

* Document the in-process semantic search model

* Add in-process semantic search integration test

* Drop incompatible legacy entity indexes

* Address embedder review findings

* Quote legacy index identifiers separately
2026-08-19 16:39:01 +02:00
Chris Truter
c9ee97eba1 Run LLM context queries with user-aware database access (#80149) 2026-08-19 13:31:13 +00:00
Nicolò Pretto
1da1d694ff fix(eajs): allow copying to clipboard in chrome (#80072) 2026-08-19 15:01:37 +02:00
Ben Grabow
ff8e7993d3 Internal load-namespaces tool to check for compilation errors (#80009)
* Internal load-namespaces tool to check for compilation errors

* Ignore load-namespaces in module checks

* lint load-namespaces

* Reject positional args

* Test the new utility

* Guard against nonexistent root.
Make the code more testable.
2026-08-19 07:56:18 -05:00
Nemanja Glumac
ac3cd1d211 Make mage driver-decisions honor the "force-run" (#80128) 2026-08-19 14:44:04 +02:00
Nemanja Glumac
22581bf881 Add e2e instructions for copilot (#80097) 2026-08-19 14:06:55 +02:00
Romeo Van Snick
5d77c90c36 Delete the reference loading slice (#80137)
* Give the reference edit forms their own save state

* Pass reference fetch state as props and drop it from the slice
2026-08-19 21:08:41 +10:00
Romeo Van Snick
2ca2e0f6d3 Carry the upload destination name in upload state (#80135) 2026-08-19 21:08:31 +10:00
Romeo Van Snick
a444fcd2af Preload the app bundles from the top of <head> (#80134)
* Preload the app bundles from the top of <head>

* Narrow the preload hint type with flatMap
2026-08-19 20:51:31 +10:00
Nicola Mometto
1a84c0616c remove unused transformer (#80117) 2026-08-19 09:41:16 +01:00
Romeo Van Snick
16906d4383 Load CodeMirror editors lazily so they leave the initial bundle (#79809)
* Load CodeMirror editors lazily so they leave the initial bundle

* Inline the single-use dynamic imports into the lazy calls

* Share the codemirror split chunk with the embedding SDK build

* Split the snippet, Python and public document pages by route instead of wrappers

* Dedupe modules shared by async chunks instead of naming CodeMirror

* Drop the duplicate metrics viewer route that master already added

* Keep barrel imports and mark the expression language side-effect free

* Move the action editor boundary to the new action-creator module

* Drop the codemirror cache group the SDK config no longer needs

* Drop props types exported for wrappers that route splitting replaced

* Delay the code fallback so a fast chunk load never flashes uncoloured code

* Render nothing while the code editor chunk loads

* Delay the whole expression widget instead of showing a spinner inside it

* Open the editors from routes and modals instead of loading states

* Fold the Metabot preview chunk into the message's own loading state

* Fold the query editor chunk into the transform pages' own loading state

* Prefetch the expression widget from the notebook so its popovers open ready

* Await the run error message now that the code editor loads lazily

* Hold the custom column popover shut until the expression widget arrives

* Wait for the expression editor to hold focus before typing into it

* Type into the page directly when asserting the read-only native editor

* Read the editor value only once its document stops changing
2026-08-19 16:02:49 +10:00
Nathan Voxland
dbe9a0acc1 Improve geojson and http requests (#80111) 2026-08-19 00:29:36 -05:00
Romeo Van Snick
3a0ac1ecba Move the segment fetch thunks into the reference module (#80059)
* Move the segment fetch thunks into the reference module

* Inject the settings endpoints in the api cache seed helper
2026-08-19 15:08:10 +10:00
Romeo Van Snick
2d2e90cf66 Fetch every plugin route factory when its route is matched (#79637)
* Fetch the routes-only plugin pages when their route is matched

* Fetch the remaining plugin route factories when their route is matched

* Load the AI settings pages as one chunk

* Say why these route files register no prefetch

* Derive the AI settings prefetch paths from the route paths

* Prefetch the model migration page like its siblings

* Count the python transform pages master already made lazy

* Give each AI settings page its own chunk
2026-08-19 15:05:14 +10:00
Nemanja Glumac
b7f4881ae8 Decide whether to build uberjar based on files changed (#80129) 2026-08-19 16:02:50 +12:00
Ryan Laurie
9b71465b85 Use eng stats api for data uploads (#79929) 2026-08-19 15:16:38 +12:00
Fraser
522b0458e0 Create FE Modularization skill (#80132) 2026-08-19 14:48:53 +12:00
Romeo Van Snick
70181282f3 Keep tiptap and prosemirror out of the initial bundle (#79964)
* Move useEntityData out of the tiptap SmartLink node module

* Load the Metabot chat panel lazily

* Load the public document page lazily

* Recover from a failed fetch of the Metabot chat panel

* Fetch the public document chunk at startup
2026-08-19 12:40:29 +10:00
Romeo Van Snick
1f9d8f05ba Give the remaining route subtrees their own chunks (#79738)
* Fetch the data studio, model and snippet pages when their route is matched

* Fetch the monitor and timeline pages when their route is matched

* Name the chunks the route loaders build

* Fetch the AI auditing and library pages when their route is matched
2026-08-19 12:37:47 +10:00
Romeo Van Snick
b8584138cb Resolve the segment thunks' table and database from responses, not the mirror (#80058) 2026-08-19 12:25:52 +10:00
Romeo Van Snick
4525787c27 Fetch reference pages through one loading bridge instead of two (#80056) 2026-08-19 09:35:51 +10:00
Nemanja Glumac
63f77fa7c3 Decide whether jobs run at all before hitting the test-plan (#80091) 2026-08-18 23:06:47 +00:00
Denis Berezin
4c73becf0e Fix e2e test flakes (#79791)
* Fix e2e test flakes

* Revert timeout
2026-08-19 02:04:21 +03:00
Egor Grushin
741ed577d9 Security Center: Markdown (#79737)
* GDGT-2977 Style full markdown output in shared Markdown component

- Markdown.module.css covers every CommonMark+GFM element as :where() base layer
- compact prop for line-clamped previews
- disallowHeading renders headings as paragraphs instead of unwrapping to text
- drop duplicated rules from AIMarkdown, delete AdvisoryCard.module.css
- kitchen sink fixture + stories + spec failing on unstyled elements

* GDGT-2977 Trim markdown specs to our own behavior

- drop tests that only exercise react-markdown/remark-gfm
- cover urlTransform (metabase://, data:image, unsafe protocols)
- assert headings render as p when disallowed
- merge AdvisoryCard markdown cases

* GDGT-2977 Fix markdown spec asserting against mantine style tag

- container.firstElementChild was the injected <style>, so every
  querySelector check passed vacuously
- render into a host element and select the markdown root from it
- javascript: urls collapse href to empty, which drops the link role

* addressing the PR's comments

* GDGT-2977 Match skeleton caption tooltip to legend caption

compact + lineClamp, per PR review

* fix small regression
2026-08-18 23:36:09 +01:00
Fraser
e81d02d15d Give dayjs a facade module (#79963) 2026-08-19 10:25:12 +12:00
Maksym Yakubych
9b159ccf78 Let conversation titles run on their own model (#78954)
* Let conversation titles run on their own model

Every LLM call read the one `llm-metabot-provider` setting, so naming a
conversation — a short, high-volume call — burned the same model the chat runs
on. Add an `llm-title-model` setting whose getter resolves rather than defaults:
the stored value, else the fastest model of the connection Metabot uses, else
that model itself. Provider types now carry a `:mini-model` alongside
`:default-model`, taken from each adapter's own supported-models list so the
fallback names a model the picker actually offers; Azure and the managed
provider have none and fall through.

Move model selection out of the AI providers card into its own Models section,
so per-use-case choices have somewhere to live. Deleting a connection clears a
title model pinned to it, reading the raw stored value so a merely-derived one
is left alone.

* Name a mini model for every provider type that has one

Mistral and Z.AI were the only types with no `:mini-model`, so conversation
titles on those connections fell through to the model Metabot chats on. Both
adapters whitelist exactly one model today, so their mini model is that same
model — the fall-through by another name, but spelled out where a real small
model goes when one is added.

Two tests hold the line. The registry map fails when a type gains or loses a
`:mini-model`, since a missing one reads as nil and quietly sends titles to
the full-size model. The listability test fails when a type names a model its
adapter will not offer: `list-models` returns the whitelist intersected with
the provider's catalog, so an id the whitelist omits never reaches the admin
picker, which renders it as a blank selection.

That second test caught Anthropic naming `claude-haiku-4-5` while the Claude
whitelist carried only `claude-haiku-4-5-20251001`, leaving the new title
picker blank on the commonest setup there is. The whitelist was the odd one
out — the adapter already defaults to the undated alias — so it loses the date.

* Sell the title model as the mini model it already was

Review asked whether each feature should get its own picker or whether one
cheap model should serve every quick task; the answer was one model. The
setting already resolved through the registry's `:mini-model` and nothing but
titles read it, so this is a rename — `llm-title-model` becomes
`llm-mini-model` — plus copy that presents naming conversations as the first
of the quick tasks rather than the point.

The derivation tests now pin the stored value to nil instead of assuming a
clean database; a crashed run had left a stored value behind, which the old
tests read as the derived one.

* Return Haiku's dated id, which is what the Anthropic catalog serves

Dropping the date assumed /v1/models would list the bare alias. Review
testing with a live key showed it does not: the catalog carries only
claude-haiku-4-5-20251001, so the undated whitelist key filtered Haiku out
of the picker, and the derived mini-model reference matched no option —
titles kept running on Haiku while the picker sat blank.

The alias stays where it always worked: inference-call defaults. The
whitelist and the registry go back to the catalog's id, which is also the
whitelist's convention — the 2025 models are dated, the 2026 models are not,
tracking what the catalog returns for each.

* Move a pinned mini model with the deployment its connection now serves

Editing an Azure connection's deployment already moves the Metabot
selection, because the reference bakes the deployment name in. A mini model
pinned to that connection went stale the same way, so it moves the same
way. A derived mini model is left unstored — it follows the Metabot
selection on its own.
2026-08-18 17:01:37 -03:00
Jeff Bruemmer
fbd41598bf docs - embedding intro edits (#80109)
* auth props

* edits

* Update docs/embedding/introduction.md
2026-08-18 19:47:56 +00:00
github-automation-metabase
b4eb9ba39f Auto-update documentation for master (#80030)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-18 15:41:17 -04:00
Maksym Yakubych
3e6fe863dd BOT-1642 Show a clearer error for a second AI-managed subscription (#80017) 2026-08-18 18:53:15 +00:00
Kevin Kelleher
da492ba066 Fix cardinality cache collisions (#79856)
* use json_query as the cache key to prevent cardinality cache collisions

* fix e2e, persist default settings that depend on cardinality

* review comments
2026-08-18 13:26:55 -04:00
Anderson
b1e7ed37dd Add setting to display percentages instead of values on stacked bar charts (#79922)
* Display percentages instead of values on stacked bar charts

Adds a Stack value format setting that lets stacked bar chart segments display their percentage of the stack total.

Addresses #67702

* Handle percetanges for negative values

* Show decimals for fractional stack percentages

* Show percentage totals for stacked bars

* Improve condition readability

---------

Co-authored-by: Abhijnya K G <53806883+Abhijnya002@users.noreply.github.com>
2026-08-18 14:21:20 -03:00
Maksym Yakubych
2c98f5d2bf BOT-1989: Restore the conversation title size on the Conversation detail page (#79996) 2026-08-18 17:13:32 +00:00
Nick Fitzpatrick
15e8fd286d Update user-visibility subtext (#78147) 2026-08-18 14:08:08 -03:00
Maksym Yakubych
35a1199a35 Support multiple LLM provider connections at once (#78860)
* Invalidate the cached models list when credentials change

A credential write can change which models the provider serves, e.g. a different Bedrock region, Google location, or
Azure resource.

* Support multiple LLM provider connections at once

Metabot could only ever be connected to one AI provider, because each
provider's credentials lived in its own hardcoded settings
(llm-anthropic-api-key, llm-azure-api-key/-base-url, llm-bedrock-*, ...)
and llm-metabot-provider named the single provider in use. Adding a
provider meant editing a `case` in six namespaces plus three constant
maps on the frontend, and two keys for the same provider were impossible.

Replace that with a list of provider *connections* stored in one
`llm-providers` JSON setting, fronted by a CRUD API rather than edited as
a raw blob -- the same shape the `oidc-providers` setting already uses.
Provider types are now a pure-data registry describing each type's
credential fields, so the admin UI renders a connection form from the
server descriptor instead of a bespoke component per provider, and the
model picker lists models from every connection, grouped by connection.

Connection keys are slugs defaulting to the provider type, so existing
`llm-metabot-provider` values keep resolving unchanged. `metabase`
becomes an ordinary connection, which collapses the three-segment
`metabase/provider/model` special case into the generic rule. The
per-provider env vars keep working and each synthesizes one read-only
connection; MB_LLM_PROVIDERS puts the whole list under env control.

* Fix managed provider handling in the connection list

Five issues from reviewing the new admin UI, all around the Metabase-managed
provider:

- The managed provider's models were fetched from the proxy, so instances
  that cannot reach it showed "Couldn't load models". The proxy serves one
  benchmarked model rather than a listable catalog, so the registry now
  carries that catalog directly and nothing is fetched. It doubles as the
  allow-list `llm-metabot-provider` is validated against, replacing the
  separate copy in metabot.settings.

- The managed provider had an Edit action that opened an empty modal (or one
  offering to re-Connect something already connected). There is nothing to
  edit, so managed connections now only offer Remove.

- It could be added twice. Provider types can declare themselves singletons;
  the API rejects a second connection and the FE drops already-connected
  singletons from the Add dropdown.

- The empty state repeated the section description verbatim.

- Cards showed the raw type slug under the name ("Metabase" / "metabase").
  The provider type's label is now shown only when it differs from the
  connection's name, i.e. when the admin renamed it.

Also drops the startup step that materialized a `metabase` connection on any
proxy-configured instance, which made the managed provider look connected when
nobody had connected it. It is now materialized only where a `metabase/...`
model reference is actually written: the legacy metabot-v3 sync, and the
migration of an instance already pointed at it.

* Address review comments on the provider e2e helpers and managed setup spec

- Drop the doc comments added to e2e-metabot-helpers.ts.

- Move the mock LLM server's base URL into the connection's own config rather
  than the legacy `llm-anthropic-api-base-url` setting. Both worked, because
  the adapters fall back to that setting when the connection carries no base
  URL, but relying on the fallback left the connection describing one endpoint
  while requests went to another.

- Assert that the add-on purchase really does precede the connection POST,
  which the test claimed in its name but never checked, and add the missing
  case: a failed purchase must not create the connection.

* Stop repeating the model family in the model picker

Providers already carry the family in the model's display name, so prefixing
it with the group produced labels like "Opus · Claude Opus 4.8" and
"Anthropic · Anthropic: Claude Haiku 4.5". Show the display name on its own.

The group still comes back from the API: the backend orders each provider's
models by it, so dropping it here keeps the grouping-driven order without
showing the label twice.

* Select a default model on connect, and simplify the ad-hoc connect view

- Connecting a provider when Metabot had nothing usable to run on now points
  `llm-metabot-provider` at that connection's default model, so the first
  provider you add leaves the instance working instead of connected with no
  model selected. An existing selection that still resolves is left alone, so
  adding a second provider does not silently switch Metabot over to it. The
  "did it have a usable model" check has to run before the new connection is
  saved: the setting's default names the `anthropic` connection, which a
  freshly saved Anthropic connection would otherwise satisfy retroactively.

- The ad-hoc "connect to a model" modal no longer renders the admin list with
  an "Add a provider" button that opens a second modal. It is one connect form,
  and once a provider exists it is the model picker. Extracted
  ProviderConnectionForm so the admin modal and this view share it.

- Show a skeleton while the connection and provider-type lists load.

- Replace the add-on ordering assertion, which compared indexes into the call
  history, with a purchase that only resolves once the test releases it: the
  connection must not be created while it is in flight.

* Fix the managed-provider-limit spec for the single-form connect view

The ad-hoc connect modal no longer offers an "Add a provider" button that
opens a nested modal, so assert on the provider picker the form now renders
directly.

* Show the model picker after connecting from the ad-hoc modal

Connecting a provider in the ad-hoc view called onClose straight away, so the
modal shut before the picker it was supposed to hand over to could render.
Stay open and show the picker instead, except for a provider that serves a
single fixed model (the managed one), where there is nothing to pick.

* Split the AI provider list and setup views apart

The one component branched on an `isModal` prop to be either the admin list
(cards, add/edit/remove, model picker) or the ad-hoc connect flow, which meant
neither was composable and the modal branch read as an afterthought. Split it
into AIProviderList and AIProviderSetup, each owning its own data and state,
sharing ProviderConnectionForm, LlmModelPicker and ProviderListSkeleton.
Call sites pick the one they mean instead of passing a flag.

Also use `useDisclosure` for the add-provider modal. `editing` and `deleting`
stay on `useState` because they carry the row being acted on rather than a flag.

* Cancel the managed AI add-on from the backend, not a plugin hook

`PLUGIN_METABOT.useOnProviderRemoved` only ran when the managed connection was
removed through that one component, so removing it any other way — the API
directly, or the fallback path in the delete handler — left the customer paying
for an add-on they no longer used.

Move the cancellation into `DELETE /api/llm/providers/:key` behind a
`defenterprise`, which is where the invariant actually holds: removing the
managed connection cancels its add-on for every caller. The EE side decides
which product type to cancel from the same premium features the frontend was
reading, and keeps the rule that an instance on legacy tiered pricing with no
metered offer to move to is left alone. Extracted the Store call the
cloud-add-ons endpoints already make into `cloud-add-ons.core` so both share it.

The plugin hook and its EE implementation are gone.

Also clear fetch-mock state between AI provider spec setups. The file grew a
second `describe`, and its routes and call history leaked into whichever suite
jest packed next into the same worker.

* Address review comments on the model picker and provider API shape

- Snake_case the two kebab-case keys the provider-type endpoint put on the
  wire (docs-url, default-model), matching display_name alongside them. The
  internal registry keeps its idiomatic kebab-case keys.
- Drop the model picker's own success toast: useAdminSetting's updateSetting
  already toasts both outcomes, so picking a model fired two toasts and the
  error path only looked unhandled.
- Show SetByEnvVar under the picker when llm-metabot-provider comes from an
  env var, so the disabled Select says why.

* Remove the unused GET /api/llm/list-models endpoint

Nothing called it: no frontend, SDK, or e2e reference, only its own tests. It
listed models for whichever connection Metabot was pointed at, which
GET /api/llm/models now covers for every connection.

It is in the generated OpenAPI spec, so this is a breaking change for anyone
calling it directly. The spec is regenerated by the openapi-self-healing CI job
rather than by hand — a local run rewrites far more of the file than this change
touches.

* Pick a provider type from an icon grid instead of a dropdown

Replace ProviderTypeSelect with a ProviderTypePicker card grid, giving each
provider type an icon the backend names in the provider-type registry.

Connections now also report the env vars that configured them, so the UI can
name them rather than just saying the connection is env-managed.

* Use real provider logos, drop the stepper, lead with Metabase

- Drop the two-step "Provider / Configure" indicator. The modal swaps content
  in place rather than paging, so it rendered as a lone empty circle next to a
  floating label and conveyed no progress.

- Ship brand logos for the five bring-your-own-key providers, mirroring how
  driver logos already work: a file per provider under
  app/assets/img/llm-providers, looked up by provider type, falling back to the
  registry's icon when there is no logo (the managed provider keeps `metabot`).
  Sourced from svgl. The three monochrome marks are rewritten to currentColor so
  they survive dark mode; Azure and AWS keep their brand colour. Bedrock uses the
  AWS mark for want of a Bedrock-specific one.

- Offer the Metabase-managed provider first when the instance can actually use
  it, since it is the option to reach for before bringing a key.

- Fix the blank managed connect screen. MetabaseAIProviderSetup decided between
  "connect" and "already connected" from `llm-metabot-configured?`, which under
  multi-provider is true as soon as *any* connection works — so adding Metabase
  alongside an existing Anthropic connection rendered the connected-state card
  for a subscription that was never bought, which draws nothing. The caller now
  passes whether the managed provider itself is connected.

The provider-types order assertions became environment-dependent once ordering
keys off proxy availability (this worktree's .env sets MB_LLM_PROXY_BASE_URL), so
they now assert the set of types plus the lead position explicitly.

* Drop the provider picker heading that repeated the modal title

* Right-align the managed provider footer to match the other setup screens

* Focus the confirm button so Enter confirms instead of dismissing the modal

* Tuck the optional connection fields behind Advanced settings

The connect form led with Display name and API base URL, so the one field
that actually matters — the API key — was buried among inputs that already
have working defaults.

Show only the fields a provider type marks required, and collapse the rest
plus Display name into an Advanced settings disclosure at the bottom. The
split comes from the field descriptors rather than a hardcoded list, so
Azure (whose base URL is required) keeps both inputs up front.

Editing a connection that already customized an optional field opens the
section, so a non-default value is never hidden.

* Let the registry say which connection fields are advanced

Inferring it from `required` was wrong for Bedrock. `required` means
validation fails without a value, which is a different question from whether
an admin has to think about the field. Region has a default, so it is not
required — but it decides which models are reachable and where inference
runs, so burying it hands EU customers a silent us-east-1 default.

Add an explicit `advanced?` to the field descriptors and split on that. Only
the base URLs and Bedrock's session token carry it; region now stays up
front where it belongs.

* Cover submitting the provider connection form with the enter key

* Treat the single-provider env vars as a supported way to configure a provider

They were named and documented as a legacy compatibility shim — `legacy-env-connections`,
`migrate-legacy-provider-connections!`, and a doc line promising they "keep working".
That undersells them: configuring one provider by environment variable is the simpler
option when you do not want to hand-write the `llm-providers` JSON, and it is resolved
from the environment on every read, so editing a variable is picked up rather than being
frozen into a copy at first startup.

Rename to say what they are, and reword the docs to position the two mechanisms as
alternatives rather than old and new. The one-shot at startup only ever adopted
credentials stored in the app DB, so name it for that instead of for migrating away from
something we are keeping.

* Type the LLM provider logo lookup and move it out of the icon component

The logo map lived inline in ProviderTypeIcon as a Record<string, string>, so a new
provider type silently fell back to the generic icon instead of failing the build.

Give provider types a LlmProviderTypeName union and key the map on it, so adding a type
without deciding on a logo is a compile error. The map moves to metabot/constants.ts and
the lookup to metabot/utils/llm-provider.ts, matching how databases/ splits ENGINE_LOGO
from getEngineLogo.

* Focus the first credential field once a provider is picked

* Show the Metabase logo for the managed provider

* Only badge a provider connection when something is actually wrong

The green check was driven by `usable`, which only asks whether the required fields are
non-blank. The connect form already refuses to submit until they are, so every connection
made through the UI got a check — including one whose API key the provider rejects, right
above a "Couldn't load models" error saying so.

Drop the check and keep the warning, which still carries a real signal for connections
that arrive incomplete from MB_LLM_* or MB_LLM_PROVIDERS, and label it so it says what is
wrong rather than just showing a red triangle.

* Lighten the provider list to divider-separated rows

Each connection was a bordered Card, but the list already sits inside SettingsSection,
which is itself a bordered, padded card — so every row read as a card inside a card.

Follow DatabaseInfoSection instead: one container, rows separated by a divider. Also drop
the icon from 40 to 32, since the tile no longer has to anchor a card of its own.

* Tighten the provider list spacing and align the add button with the rows

The list and its add button sat in the same lg-gap stack as the model picker, so a control
that belongs to the list was pushed 24px away from it, as far as the next setting. Group
them in their own sm stack and keep lg for the gap to the picker.

The subtle button also carries horizontal padding, so its + sat indented from the provider
logos below it. Drop the padding, matching the Advanced settings toggle on the connect
form, so both start on the same edge.

Tighten the section header gap from lg to md via the stackProps SettingsSection already
exposes for this.

* Fetch provider models once and report failures on the provider that failed

A model-loading failure was only visible under the model picker, which reads as a problem
with the picker rather than with Anthropic. It also meant the failure was invisible
anywhere a picker was not rendered.

/api/llm/models already returns every connection in one response with a per-connection
error, and RTK Query already caches it under the llm-models tag, so the fetch is shared by
construction. Wrap it in useLlmConnectionModels, which hands back the grouped select
options and the errors keyed by connection. Any future picker goes through that hook and
gets the same cache entry and the same keying, rather than re-deriving either.

The provider row now renders its own error and the picker no longer lists them; the picker
keeps the request-level error, which is a different failure.

* Pick the provider from a pasted API key

Paste a key anywhere on the provider grid and it jumps straight to that
provider's form with the key filled in, ready to connect.

The registry already declared a `:prefix` per credential field — the backend
validates against it — so this just exposes it and reuses it for recognition.
Anthropic, OpenAI and OpenRouter carry one; nothing else is guessable.

Longest prefix wins, since OpenAI's `sk-` also matches an Anthropic
`sk-ant-` key. Text that matches nothing is left alone.

* Leave the paste-to-detect shortcut unannounced

Drop the hint under the provider grid. The shortcut stays; it is just for
people who find it. `canRecognizeApiKeys` existed only to decide whether to
render that line, so it goes too rather than sitting unused.

* Gate the managed provider usage panel on the features that fill it

The panel was shown whenever the metabot plugin was enabled, which is a
weaker condition than having usage and pricing to report. Move the check
behind a hasMetabaseManagedProviderDetails plugin hook so the enterprise
side decides from the premium features, and OSS answers false.

* Register the Mistral and Z.AI provider types in the connection registry

master added both providers to the single-provider settings architecture this branch
replaces, so rebasing dropped them from the admin UI: the adapters, the model listers and
the MB_LLM_MISTRAL_*/MB_LLM_ZAI_* settings all survived, but nothing offered either type
as something an admin could connect.

Registry entries restore that, and adding them to `single-provider-settings` is what keeps
an instance already configured through those env vars working — the same entry drives both
the env-resolved connection and the app-DB adoption at startup.

* Resolve provider connections in the LLM call tests

`parse-provider-model` stopped deriving a provider from the model string's first segment
and started resolving it against a configured connection, but this file still called it
with nothing configured, so every test that reached an adapter failed on "No LLM provider
connection named". Configuring the default connections is the fixture those tests were
always implying.

The invalid-format cases now assert the error the new resolution actually raises: an
unparseable string and an unknown connection are the same failure, because a name that is
not a connection is all "unknown provider" can mean once the registry is the source.

* Move the onboarding AI step onto the provider connection list

The setup wizard's AI step was built against the single-provider form this branch removed,
so it still imported `AIProviderConfigurationForm` and the provider enum and no longer
compiled. It now renders `AIProviderSetup`, the same connect-then-pick-a-model view the
ad-hoc modal uses, so onboarding and the admin page stay one flow rather than two.

Preselecting `metabase` goes away with the dropdown it was written for. The backend already
sorts the managed provider first in the type picker, which is what the preselection was for,
and picking it in a grid of logos is not a step the wizard needs to skip on the admin's
behalf.

* Add the Mistral logo and make a missing provider logo fail the build

LlmProviderTypeName is written by hand and had drifted: the registry gained mistral and
zai, the union did not, so the exhaustive LLM_PROVIDER_LOGOS record was exhaustive over a
stale set and both types fell through to the generic icon without a type error.

Add them to the union, which does make the record fail to compile until each is decided.
Mistral gets its mark from svgl; svgl has no Z.AI logo, so zai is an explicit null and
keeps the fallback icon until we have a real asset.

Nothing links the registry to the union, so add a backend test over the registry's type
names that names both frontend files to update.

* Thread per-connection credentials through the Mistral and Z.AI call paths

Both adapters took :credentials in list-models but ignored them in mistral-raw/zai-raw,
which read llm-mistral-api-key / llm-zai-api-key directly. So a connection's models listed
under its own key while every actual chat call went out under the global setting: a second
Mistral connection would silently answer with the first one's key, and a connection with no
global setting configured would fail outright.

Match the openrouter/claude shape — credentials first, configured settings for whatever
they do not carry — for both the key and the base URL, and cover the call path, which no
adapter had a test for.

* Give Z.AI its logo

Taken from z.ai's own logo.svg, since svgl carries no Z.AI mark. Stripped the Illustrator
export's ~10.5KB of unused .st0-.st194 declarations — only two of them were referenced —
and kept the geometry and colours exactly as shipped.

Every registered type now has a mark, so the fallback is only reachable when the backend
serves a type this union has not caught up with. Test it that way instead.

* Let instances that are only offered managed AI reach the purchase flow

The managed provider tile is enabled by `type-available?`, which asks whether
the LLM proxy URL is readable. That setting was gated on `metabase-ai-managed` or
`metabot-v3` — features granted only by the AI add-on products — while
`offer-metabase-ai-managed` comes from the starter and pro-cloud plans. Every
cloud instance eligible to buy the add-on therefore saw the tile as unavailable
and had no way to reach the purchase flow that grants the feature.

* Cover per-connection credentials on the remaining providers' call paths

Every provider tested credentials through list-models and none through the call path,
which is why Mistral and Z.AI could list models under a connection's own key while sending
every chat request under the global setting.

Claude, OpenAI and OpenRouter resolve credentials inline like Mistral did, so they get the
same explicit/blank pair. Azure and Bedrock instead forward :credentials into a shared
request helper the list-models tests already exercise, so what is worth pinning there is
the forwarding: with no settings saved, the request only goes out if the passed-in
credentials arrive.

* Pin every provider type's default model

The removed settings-put-connect-*-defaults-model tests asserted this per provider; the
connection API replaced them with one test that a first connection gets a model selected,
and provider-types-test only spot-checked anthropic and azure. A typo in mistral's or
zai's :default-model would have shipped silently.

* Alias metabase.llm.test-util as llm.tu, not lct

The letters do not spell anything in the namespace, so at a use site like
lct/default-connections there is no way to tell which namespace it came from. The repo
convention for a module's test utilities is <module>.tu — lib.tu, notification.tu.

Add it to kondo's :consistent-alias map too; it was absent, which is why the alias could
be picked freely in the first place.

* Make the whole provider header toggle the usage details

Only the chevron was clickable, so the icon and name next to it did nothing — unlike
Accordion.Control, which is what makes the entire header hit the toggle everywhere else.

Promote the header itself to the control and leave the chevron as the affordance, with
the options menu as a sibling rather than nested inside it. SetByEnvVar moves below the
header for the same reason: it carries a docs link, and a link inside a button is invalid.

Not Mantine's Accordion, because expandability is per-row here — only the managed provider
has a panel, and wrapping the rest would give them a control that opens nothing.

* Let the provider modal play its closing animation

The modal hardcoded opened and the parent mounted it conditionally, so closing unmounted
it outright and Mantine never got to run the exit transition. Opening animated because
mounting still runs the enter one.

Drive it with opened instead and keep it mounted, the way ConfirmModal a few lines below
already does. Clearing the edited connection moves to onExitTransitionEnd so the title
does not flip to "Add a provider" on the way out.

* Fix the edit modal never closing

Openness was derived from isAdding || editing != null, but closing only cleared isAdding
and left editing to be cleared after the exit transition — which could not start, because
editing still held the modal open. Editing a provider trapped it.

Track openness in its own disclosure so closing is one flag, and let editing carry only
which connection to show, cleared after the transition. Adding worked before, which is why
the existing test missed this; cover closing from edit too.

* Revert the provider modal close-animation work

Reverts 7e78358857d and 1a7c37d6412. The missing close animation is not this component's:
the Modal theme attaches enter-only CSS keyframes (popInFromBottom, fadeIn) to every modal
in the app and animation.module.css defines no exit counterpart, so driving this one with
opened bought nothing but state to keep in sync — and the first attempt at it trapped the
edit modal open.

Back to mounting it conditionally. Keeps the fetch-mock teardown from the reverted commit,
which is unrelated hygiene: without it this spec's routes leak into
MetabotAgentDataSourcePills and fail it in a combined run.

* Serve a request from its own connection's credentials, nothing else

Every adapter fell back to the single-provider `llm-*-api-key` setting when
the credentials it was handed came up blank. That made sense while the two
systems coexisted, but the env vars are themselves connections now, so the
fallback could only ever fire by lending one connection another's key — a
connection whose key an admin cleared would silently keep working on someone
else's, while `connection-usable?` reported it as broken.

Resolution now fills each field's registry default into the config, so
`:credentials` carries everything the adapter needs, including the base URL
it used to read from the settings. The adapters take credentials and nothing
else, and throw when they are missing.

Tests drive the adapters the way production does — by passing credentials —
rather than by setting a global and calling with none.

* Finish removing the settings fallback: Mistral, Z.AI, and the docs

Mistral and Z.AI landed alongside the previous commit and were copied from
the old shape, so both still reached for their `llm-*-api-key` setting when
the credentials came up blank. A half-applied invariant is worse than either
state — the next adapter gets copied from whichever one you happen to open.

The docstrings across all seven still advertised the fallback I had already
deleted, including the canonical `LLMRequestOpts` contract. Code that lies
about itself in the one place people go to read the rule is how the rule
comes back.

* Drop the editorializing line from the credentials doc

The entry should say what the field is, not argue for it.

* Reject runtime writes to the per-provider credential settings

These configure a connection only when set by an environment variable, which
llm.provider/connections resolves on every read. Writing one to the app DB landed somewhere
nothing reads: the settings API returned 200 and the request kept using whatever the
connection held. The e2e specs are the proof — one pointed llm-anthropic-api-base-url at a
mock server and would have talked to the real Anthropic instead.

Mark all sixteen :setter :none, so a write raises "you cannot set X; it is a read-only
setting" instead. Tests keep working because with-temporary-setting-values passes
:bypass-read-only?; connections are managed through /api/llm/providers.

Give each a :doc, since env-var-dox drops a read-only setting from the generated docs unless
it has one, and format-config-name then labels them environment-variable-only.

The custom setters and normalize-llm-base-url are unreachable now, so they go; base URLs on
connections were never normalized, only the setting path was.

* Disable the managed-AI Connect button instead of hiding it

An admin who had not ticked the Terms box saw no Connect button at all, so
the modal gave no sign that connecting was possible or what unlocked it.
Show it disabled: the checkbox is now visibly the thing standing in the way.

The gate moves out of `connectAction`, which keeps meaning "there is nothing
to do here" — already connected, or not an admin — and stays null in those
cases so the button remains hidden.

* Let a connection name the model it serves, so Azure is selectable

Azure's listing endpoint returns the regional catalog rather than the customer's
deployments, so list-models always came back empty and the picker had nothing to offer: you
could connect Azure and never pick a model. Master collected the deployment alongside the
credentials; the connection API dropped that and nothing replaced it.

Give the registry a :model-field naming the config key holding the model, and Azure a
required Deployment field. /api/llm/models still makes the call — it is what verifies the
credentials — but reports the configured deployment instead of the empty list. The connect
form needs no frontend change: it is descriptor-driven, so the field renders itself.

Two things this pulled in. config-complete? now demands the deployment, which is right for
"can this connection serve a request" but wrong for the adapter's auth check, so that
gets credentials-complete?, which ignores the model field. And the environment path needs
to express the deployment too, or an env-configured Azure connection reports unusable, so
llm-azure-model joins the single-provider settings.

Also give the unsupported-model error a 400: the deployment is typed by hand, so a bad
prefix is a bad request, not an outage.

* Import the provider logos instead of pointing at asset paths

The filename map plus a path-building helper came from ENGINE_LOGO, but that is the older
pattern: 57 places import an SVG as a module, and DatabaseHostnameWithProviderField's
ProviderIcon does exactly this — vendor logos colocated in the component directory, imported
and mapped by provider name.

So colocate them and import them. The bundler resolves the reference, which means a renamed
or missing file fails the build rather than 404ing at runtime, and the path string stops
being something to keep in sync by hand. The map moves next to the imports, which drops
metabot/utils/llm-provider.ts and the constants entry entirely.

The per-file src assertions go: jest maps every SVG to one stub, so what is left to check
is which of the three marks renders — the Metabase logo, a vendor image, or the fallback.

* Judge the onboarding AI step on a usable connection, not the first listed

connections[0] answered three questions it could not answer well. A listed connection is
not necessarily one that can serve a request — one configured by env var with a field
missing reports usable false — so onboarding would call itself connected, hide the skip
button, and report that provider to analytics.

Ask for the first usable connection instead. The skip button also waited on the list being
empty, which is equally true while the request is in flight, so it flashed on every load;
gate it on isLoading as well.

The completed-step title inferred skipping from a missing label, but the label comes from
the provider-types request, so a connected provider with that request still in flight read
as skipped. Pass whether a provider connected separately from what it is called.

* Match the loading skeleton to the list it stands in for, and colocate it

It still described the card-per-connection layout: two 4rem blocks with rounded corners and
a gap between them. The list has been divider-separated rows for a while now, so the
skeleton was a size and a shape that no longer appear anywhere.

Rebuild it from the row it mimics — PROVIDER_ICON_SIZE plus the row's py="sm" — with a
divider between and the button that follows, and move it into AIProviderList.tsx beside
those constants, so the next layout change has it in view rather than a file away.

Cover it too: nothing asserted the skeleton rendered at all, which is how it drifted.

* Keep the Metabase mark on the managed provider when whitelabelled

LogoIcon resolves through PLUGIN_LOGO_ICON_COMPONENTS, which the whitelabel plugin fills
with the customer's logo. That is right nearly everywhere, and wrong here: this row is
Metabase's own hosted AI service, so a whitelabelled instance was labelling it "Metabase"
— the registry string is not rebranded — beside someone else's logo.

Use DefaultLogoIcon, as WelcomePage and SdkUsageProblemBanner already do where the mark has
to be Metabase's regardless of branding.

* Stop standing in for an unknown provider with a real one

Gemini is a provider we could plausibly add, and the day we do, every test asserting it is
unknown starts asserting the opposite of what it means. Use a name nobody will ship.

Same for the Azure family placeholder, which picked gemini for a family Azure does not
serve. The genuine google/gemini-2.5-flash model references stay.

* Match on the secondary button instead of nesting a && inside a ternary

isEditing ? (onCancel && <Cancel/>) : <Back/> reads as two branches but is three, and the
one that renders nothing is the one you cannot see. The rest of this component already
picks its shape with match, so this now does too — and onCancel is narrowed by the pattern
rather than guarded separately.

* Pick the Azure model provider from a dropdown again

The rewrite collapsed master's Model provider select and Deployment name
input into one text field where the admin types `openai/` as a prefix. Split
them back apart and compose the model from the two, so the wire family is
picked rather than spelled.

`:model-field` becomes `:model-fields`, a vector composed with `/`, which is
the shape the Azure adapter already parses back out. A required field the
registry gives a `:default` now counts as filled — the form shows it
pre-selected, and resolution supplies it — so the family select does not
block Connect on a connection that simply accepted it.

* Drop two explanatory comments

Both restated what the line below already says.

* Shrink the loading skeleton to the row's content height

The skeleton painted the full 3rem row, padding included, so it read as two
heavy slabs butted against the divider. Wrap it in the row's own py="sm" and
size the bar to the provider icon instead: the row still occupies the same
height, so nothing shifts when the list arrives.

* Name the provider on the closed model picker

A model name alone does not say who serves it, so the picker gave no way to tell which
provider was selected without opening it. With several connections — two Anthropic keys,
say — that is the part you most need to see.

The option label carries the provider, since that is what a closed Select renders. The
dropdown keeps rendering the model alone through renderOption, because the group heading
above it already names the provider.

* Only treat a usable connection as one worth moving on from

Setup moved past the provider picker as soon as any connection was listed,
while the step around it counted only usable ones. A connection that exists
but cannot serve a request landed between them: the picker was replaced by a
model dropdown with nothing in it, the step still offered to skip, and there
was no way to connect anything.

That is reachable, not theoretical — the single-provider env vars synthesize
a connection as soon as one credential field is set, so MB_LLM_AZURE_API_KEY
on its own produces one Azure needs three fields to use.

* Serve the disconnect test's stream from the connection under test

The fake OpenRouter server was reached through the per-provider base-URL
setting, which no longer feeds a request now that credentials come from the
connection: the stream went to openrouter.ai and the turn finalized as an
error rather than an abort.

Disabling clj-http decompression on the LLM request also had to move from
http/post to the fn the adapters actually call, so the aborted stream no
longer races a ZLIB EOF against the cancel.

* Stop shipping an icon name the frontend never reads

The frontend keys its logos on the provider type and fails to compile for a
type it has no logo for, so the registry icon was only ever reachable for a
type the server knows and the client does not — which cannot happen when the
two ship as one artifact. Seven of the eight entries said "ai" anyway, and
the eighth said "metabot" for the managed provider, which is drawn from the
Metabase logo component before the icon is consulted.

Drop it from the registry, the response, and the type, and let
ProviderTypeIcon own the generic fallback it was already defaulting to.

* Point the setup wizard's AI e2e tests at the connection API

All three drove the single-provider flow this branch replaced: a Provider Select that is
now a grid of buttons, and PUT /api/metabot/settings, which no longer exists. The Anthropic
one also aimed the mock server via llm-anthropic-api-base-url, which stopped being writable
when those settings became read-only — it now types the base URL into the connection's own
advanced settings, which is where a connection reads it from.

The managed test additionally stubs GET /api/llm/provider-types and /api/llm/providers: the
managed type is only connectable once the LLM proxy is configured, and that has no backing
in e2e, so it is patched the same way the token features already are.

* Match the required API key label the way the unit specs do

Mantine appends a required marker to a required field's label, so the setup
spec's exact-string lookup found nothing once the API key field came from the
provider registry with :required? true. The unit specs already match on a
regex; the e2e spec was the last one on the bare string.

Also name do-with-entitlements! for the thread-unsafe settings it binds, which
Kondo flags.

* Edit the stored connection list rather than the environment-resolved one

The environment winning on read is right, but writes rebuilt the list from
connections, where an env-configured connection replaces the stored one with
the same key. Saving anything else then dropped the shadowed credentials from
llm-providers for good, so taking the env var back off did not restore them.

Editing the stored list also lets PUT report a shadowed key as env-configured
instead of 404ing, and stops the startup managed-connection write from landing
in the app DB when the whole list comes from MB_LLM_PROVIDERS -- which would
have pointed Metabot at a connection that never resolves.

* Configure an Azure deployment from the environment the way the form does

MB_LLM_AZURE_MODEL landed under a :model config key that nothing reads --
azure declares :model-fields [:model-family :deployment-name] -- so an
environment-configured Azure connection composed no model and counted as
incomplete, which hid the Metabot UI instance-wide behind
llm-metabot-configured?. Replace it with one variable per config key.

The same completeness rule dropped Azure from the startup adoption entirely,
because the credentials that existed before this branch never named a
deployment: they carried it in llm-metabot-provider. An upgraded instance was
left pointing at a connection that did not exist, with Metabot failing on every
request. A deployment names what to call rather than what authenticates the
call, so stop requiring it, and recover the pair from the model reference
during adoption so the connection can still offer it in the picker.

* Add a provider alongside the managed one instead of cancelling it

Running out of included tokens is a reason to bring your own key, not to end the
subscription. "Use a different AI provider" was wired straight to the add-on
cancellation: it charged ahead with no confirmation, offered no provider to
switch to, and left the metabase connection and llm-metabot-provider pointing at
each other afterwards, so nothing on screen changed.

Let the button open its own modal again. AIProviderSetup short-circuits to the
model picker once any connection exists, which is exactly wrong here, so let a
caller start on the connection form; the managed connection is left alone and
the model picker follows the save. Removing the connection outright is already
the list's Remove action, which cancels the add-on server-side.

* Let the single-colour provider marks follow the theme

The marks were loaded through <img>, and an SVG in an <img> is its own document:
currentColor resolves against that document's initial color, black, with no way
to reach the color the tile sets. Anthropic, OpenAI and OpenRouter therefore
painted black on a near-black tile in dark mode, and Bedrock's navy "aws"
wordmark left only the orange smile visible.

Inline those four instead, so currentColor picks up the tile's text colour --
dark on light, light on dark, which is how each brand publishes its monochrome
mark. Mistral, Z.AI and Azure keep their own colours and stay images. Bedrock's
wordmark becomes currentColor, reproducing AWS's own light and dark variants.

OpenRouter pointed at a clipPath the file never defined; harmless while it was
sandboxed in an <img>, but an id to collide with once inlined.

* Optimize the provider logos and normalize them to one size

The files came from seven different sources and showed it: viewBoxes ranged from
24x24 to 512x512, some carried fixed width/height, and each baked in its own
arbitrary padding. Rendered into the same 20px tile the marks came out anywhere
from 12px to 20px tall, so the row read as a jumble.

Run everything through SVGO (viewBox kept, dimensions and editor cruft dropped),
then re-cut each viewBox to a square centred on the mark's real ink extent. Every
mark now fills the tile's width, with padding controlled by the tile alone rather
than by whoever exported the file. Z.AI is inset 12%: its mark is itself a filled
rounded square, so at an equal bounding box it reads far heavier than the open
marks.

* Say when the provider lists fail to load

Both components defaulted the two queries to empty arrays and never read
their error, so a failed request was indistinguishable from having nothing
configured. The provider-types failure was the worse of the two: it rendered
an empty picker grid — no providers to choose, no explanation, no way on.
The connections failure is quieter but wronger, since an admin with several
providers would be told to connect their first one.

Report the error where the list or the picker would have gone, using the
same getErrorMessage the rest of this feature already uses.

* Tighten the provider section's spacing and hang the icon off the name

Sloan flagged three gaps as feeling high even where they match other sections: drop the
section's header gap from md to sm, and the gap out to the model picker from lg to md. The
row-to-button gap was already the tightest of the three and stays.

The icon was centred against the whole text block, so it sat slightly off the name and
drifted further as an error or env-var line appeared beneath it. Give the name the icon's
height and centre it there, so the two line up on one row however many follow.

* Collapse the add button's box, which was most of the spacing

Trimming gaps barely moved this because the space around "Add another provider" was not a
gap. Metabase's Button defaults to size md, so it is 42px tall; p={0} removes the padding
but not the height, leaving its ~20px label centred with about 11px of dead space above and
below it. That is the two gaps flagged around the button, and no gap value could reach them.

Let it take its label's height once it is an inline link. Rows go from py="sm" to "xs" as
well, since a 32px icon does not need 8px above and below it.

* Give the add button its height back

Collapsing it to its label bought vertical space but cost the button its shape. Keep the
tighter rows from the same change.

* Draw every provider logo on one 24x24 grid

Making each viewBox square left seven different squares -- 24, 88, 300.23, 256,
259.53, 511, 30.72 -- so the files only matched once a consumer sized them. Bake
each mark's scale and centring into its path data instead, so every file is
literally width=24 height=24 viewBox="0 0 24 24" with the mark centred on that
grid and its longest side spanning it.

Z.AI keeps its 12% inset, for the same reason as before: its mark is a filled
rounded square and reads heavier than the open marks at an equal box.

* Sit the row actions on the icon band and cut the gap to the add button

* Fit a provider's name and type inside the icon's height

* Give each provider row a little more room

* Inline the remaining provider logos instead of loading them by URL

Mistral, Z.AI and Azure rendered as broken images. Their files are valid; the
copies the bundler emitted were not. BannerPlugin is configured with no test, so
it prepends the licence header to every emitted asset -- and re-applies it on
each watch rebuild, so the served .svg started with six stacked comment blocks
before its <svg>, which no browser will parse as an image. Editing these files
repeatedly is what re-emitted them and made it visible.

Inline all seven the way the other four already were: SVGR turns them into
modules, so nothing reaches the asset pipeline and one import style covers the
set. Azure's gradients were on ids a/b/c, which are too generic to share a
document with, so they are namespaced now.

The banner covers every other emitted asset too, so any .svg re-emitted during a
dev session hits this; that is worth fixing in the build config separately.

* Call the managed provider Metabase AI service and tighten the add-provider modal

Drops the redundant 'Selected provider' subtitle and 'About Metabase AI service'
heading, puts the free-token and pricing lines in their own callout, and swaps the
row's ellipsis button for a neutral ActionIcon.

* Pin the base URL the credential-adoption tests read

* Mock the provider endpoints the onboarding AI modal calls

* Give the explorations e2e helper a provider connection instead of read-only settings

* Make Moonshot AI a provider type you connect to

* Regenerate the settings docs

* Answer the review on the model listing endpoint

- Drop the per-provider :group heuristics. The picker groups by connection
  name and never reads the field, so it was computed on every admin page load
  for nothing.
- Key the model cache on a hash of the connection's config rather than on the
  config itself, so a credential is not held in a process-wide cache, and a
  rotated one gets its own entry instead of the old list.
- Take the model a connection composes from its own config into account when
  Metabot's selection has to move: Azure has no default model, so it was never
  a fallback, and editing its deployment left the selection naming one that no
  longer exists.

* Migrate the per-provider credential settings instead of adopting them at startup

The startup adoption left the settings it copied from behind, frozen: nothing
writes to them any more and they are read-only, so an instance rolled back to
code that still reads them would run on whatever was last stored there rather
than on what is configured. A reversible migration fixes both ends — it clears
the settings on the way up, and writes the connection list back into them (and
rewrites a model reference older code cannot resolve) on the way down.

Also pulls the copy of `trimmed-string` the two LLM namespaces shared into
`metabase.util`.

* Wait for the model list before opening the picker in its spec

* Make Google Gemini a provider type you connect to

Carries the Gemini Enterprise Agent Platform provider over to the connection
list: a registry entry with the same form every other provider gets, credentials
resolved from the connection rather than from the `llm-google-*` settings (which
become environment-only like the rest), and the connection naming the model it
serves — the platform has no listing endpoint worth trusting, so connecting
validates the model with a free countTokens probe, the way Azure validates its
deployment.

* Cover the Google credentials in the settings migration

* Name the Gemini models Metabot works best with

* Use Moonshot AI's own mark, not Kimi's

The file was Kimi's rounded-square K — Kimi is Moonshot's chatbot, not the
company we connect to. Icon sets ship the two separately and it is an easy
one to pick wrong.

Replaced with the MoonshotAI mark, kept in the same shape as the other
single-colour logos here so it takes the theme's text colour.

* Give Google the form the upstream PR built for it

Three field kinds the registry could not express before, so the connection form
can offer what the settings page did:

- a fixed model list, replacing the free-text field and the paragraph of advice
  about which IDs work;
- an authentication method to pick, rendered as a segmented control;
- a file to upload for the service account key, read into the config the way
  every other credential is typed into it.

A field can now hang off another one's value (`show_when`), which is what makes
the credential follow the method — and a field the form hides is cleared on
save, so switching methods drops the credential it replaced.

* Let Gemini's models come from the shared picker

A connection only names its own model when nothing else can name it — Azure,
whose deployments its listing endpoint does not return, and which serves one of
them. Gemini's models are a known list, so pinning one per connection put a
second model picker inside the form and hid the other model from the one below
it. The list becomes the type's fixed catalog instead, the way the managed
provider's is, and both models show up per connection in the model picker.

A fixed catalog no longer skips the provider call: that is what verifies the
credentials, so it is made against the catalog's first model and the catalog is
what comes back. The managed provider still answers from the registry alone —
there is nothing to call, and instances that cannot reach the proxy would fail.

* Answer the migration review notes

Rename the frozen table's :credentials to :required-keys — they are the keys a
provider needs to count as configured, and "credentials" made the optional ones
sound like something else.

Stop the downgrade encrypting every value it writes. write-setting! ran
maybe-encrypt unconditionally, so base URLs, regions and the model reference —
settings declared :encryption :no — came out of a rollback holding ciphertext.
Reads decrypt opportunistically so nothing broke, but a setting should hold what
its declaration says it holds. Only the :sensitive? settings are encrypted now,
and a test pins the raw storage both ways.

The Google row is also renamed to Google Gemini Enterprise alongside the
registry, to keep the adopted connection's display name in step.

* Close the review's holes in the provider registry and its API

Redact the Google service account key. secret-field-keys covered only
:password fields, and the key is a :file upload, so GET /api/llm/providers
returned the whole credential — private key included — in plaintext. Fields can
now be marked :secret? regardless of input type, which also makes the masked
echo-back flow cover the key on edit.

Require what Google actually requires. Every field was individually optional
because either credential will do, so an empty config counted as complete —
usable: true on a connection that can serve nothing. Provider types now declare
alternative credential groups (:required-any): a service account key on its own,
or an OAuth token together with the project ID it bills to. Completeness and
validation both honor the groups.

Treat blank credentials as absent. The form clears the field it hides when the
authentication method changes, but the empty string it sent was stored and
passed to the adapter, which fed "" to the service-account parser — the
"no JSON input found" from the review. Blank values are dropped on save, and
the adapter trims what reaches it so an env-supplied blank cannot shadow the
other credential or the project ID a key carries.

Reserve the metabase connection key. Any type could claim it, splitting
managed-model-ref? from managed-type? — a bring-your-own-key connection whose
requests would be tagged as proxied in metabot_message and confuse the managed
free-tier limit. Only the managed type may hold the key now, a squatter blocks
managed connection with a clear message instead of colliding, and the unused
proxied-model-ref? is gone.

Also: per-field :validate hooks in the registry (Google's project ID and
location get the setting-specific validation the old defsettings had), select
values are checked against their options, with-entitlements takes a kwargs map,
and the cond-> in self.clj is realigned.

* Pick the Google model in the connection form

Connecting a Google provider validates the credentials with a countTokens probe
that needs a model, but the form collected none, so the probe ran against a
hardcoded default the chosen location may not serve — a confusing rejection the
admin had no control over, and no guarantee the model they wanted works at all.

Provider types now ship their fixed catalog and alternative credential groups to
the frontend. A type with a catalog gets a model picker in its connection form,
the probe runs against the picked model, and Metabot starts on it. The Connect
button also honors the credential groups, so it no longer offers to connect a
Google provider with nothing filled in.

* Quiet the kondo warnings CI caught

u/lower-case-en over str/lower-case in the reserved-key check, and the
validate-config! additions split into their own deftests instead of growing the
original past the one-concern-per-test lint.

* Report the provider Metabot landed on, not the first usable connection

The setup step resolved its connected provider with find(usable) over the
connection list, which is insertion order. Connect two providers during setup
and pick the second one's model, and the step still reported the first — in its
title and in what submitAiConfig records. Resolve the connection from the
llm-metabot-provider reference instead, the way the pre-connection-list code
compared against the saved value.

* Spell the field-level secret flag :sensitive?

Same word as the defsetting option it mirrors, so a grep for :sensitive?
surfaces the registry's secret fields alongside the settings.

* Give the LLM settings migration its own test file, and a managed round trip

Twice now a reviewer could not find the migration's coverage, because the tests
sat in the shared custom_migrations_test and referenced the migration only by
its changeset id. They live in a file mirroring the migration's path now, with a
docstring naming the change class that connects the two.

The round trip crisptrutski asked about also surfaced what he predicted: the
managed connection is not in the migration's provider table, so every rollback
of a managed-AI instance logged a false 'no equivalent setting: metabase'
warning. It holds no credentials and its model reference survives the rollback,
so it is dropped silently now, and a new test walks the managed round trip.

* Let the environment shadow connections field by field, and settings write through

Answers crisptrutski's review of the environment and settings story.

The MB_LLM_* variables now shadow a stored connection's config per field, the
way an env var shadows any other setting, instead of swapping the whole
connection out. A lone MB_LLM_ANTHROPIC_API_BASE_URL reaches the stored
connection's base URL rather than doing nothing; everything the environment
does not supply stays editable, and env_fields tells the form exactly which
inputs to disable. A standalone env connection still only comes into existence
when a credential variable is set — which is why Azure's base URL is no longer
marked as one: alone it used to synthesize an unusable connection that shadowed
the stored credentials wholesale. A metabase/ reference pinned by
MB_LLM_METABOT_PROVIDER synthesizes the managed connection it names, so that
reference resolves without an app-DB row.

The per-provider settings are a view over the connection list rather than
read-only. :setter :none broke config.yml provisioning outright — the
provisioner calls setting/set! on every entry and nothing catches the throw, so
the instance failed to start — and orphaned generate-sql and semantic search,
which still read llm-anthropic-api-key and llm-openai-api-key directly after
the migration deleted the rows underneath them. Getters resolve the connection
field with the environment and registry defaults layered in; setters write
through to the connection, creating it on first write and keeping the old
setters' field validation. Both docs files are regenerated: the config
template carries the settings again.

Also from the review: deleting the managed connection requires superuser — it
cancels the Store subscription, and everything else that can cancel add-ons is
superuser-only — and the confirmation dialog says so; the automatic
llm-metabot-provider writes (first connection, edit follow-up, delete
fallback) are skipped when the env var pins the selection, restoring what
check-not-env-shadowed! used to guarantee; the edit form starts the model
picker on the model the connection is actually serving and a changed pick
follows through to the selection for fixed-catalog types; and redact masks
every field of a provider type the registry does not recognize, instead of
none of them.

* Reconcile the restack onto bot-1875-gemini-models-fe

Mechanical conflict resolutions during the rebase left a few files at the wrong
side of the divergence; this restores the reviewed merge result: the shared
tool-function conversion in the Claude and OpenAI adapters, the settings
endpoints stripped from the relocated metabot API slice, and
gemini-3.7-flash — new on the Gemini branch — joining the fixed catalog.

* Restore the stop-reason propagation the restack dropped from the adapters

* Strip trailing slashes off base URLs when a connection resolves

* Slug-proof the reserved key, follow the effective config, and seed the models cache

* Count a blank-config connection as dropped on rollback

* Warn that deleting the anthropic or openai connection turns off the feature reading it

* Regenerate the environment variable docs against current master

* Drop the env-var doc section master removed in the backend hardening

* Update src/metabase/llm/settings.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Mark the dev rollback subqueries so the app-DB honeysql guard allows them

* Gate the settings-access delete test on EE, where advanced permissions exist

* Wait for the AI setup buttons to render before clicking them

---------

Co-authored-by: Mike Appleby <86076+appleby@users.noreply.github.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-08-18 13:42:53 -03:00
Anderson
6ab7bfb2c6 Add tracking events for collection filtering, selection, pinning, and move interactions (#79926)
* Add collection interaction analytics

* Code cleanup

* Address review comments
2026-08-18 15:37:24 +00:00
Nicola Mometto
50a3a1177d Align hardening with x.63 backport (#80066) 2026-08-18 16:27:27 +01:00
Denis Berezin
461830362b UXW-3641 Remove emotion from EntityItem (#79848)
* UXW-3641 Remove emotion from EntityItem

* Review fixes
2026-08-18 18:19:44 +03:00
Chris Truter
2550ef763e Add embedding provider SPI and Arctic embedder plugin (#78310)
* Add a versioned embedding provider SPI

Summary

- Add a runtime registry for embedding providers with readiness, model resolution, preparation, and batch embedding.
- Define immutable embedding-space descriptors and legacy deterministic identities.
- Validate provider results, dimensions, counts, and finite numeric vector components.
- Make SPI version 1 a compile-time constant; provider artifacts must embed the literal version they were built against.

Scope

This defines the neutral provider contract only. It does not migrate semantic-search consumers or ship a provider plugin.

Testing

- Registration and version compatibility.
- Identity stability and revision round trips.
- Registry snapshot consistency and malformed provider output.

* Rename shadowing local in provider/prepare!

The local bound from :prepare! shadowed the prepare! var (eastwood local-shadows-var).

* Add the Arctic embedder plugin

Summary

- Build a separate runtime plugin containing the pinned Snowflake Arctic embedding model and DJL/ONNX dependencies.
- Bundle arm64 and AVX2 exports, lazy-load native inference, and cap local batches.
- Register literal embedding SPI version 1 and derive immutable space identity from the exact model/runtime bundle.
- Fail readiness closed for unsupported platforms, musl, and glibc older than 2.34.
- Disable DJL network telemetry and revision-address bundle URLs so model upgrades cannot reuse stale cached content.

Scope

This PR ships and registers the provider but does not yet select it from semantic-search settings. Consumer integration follows upstack.

Testing

- Model bundle and plugin uberjar build tests.
- Catalog, classloader, platform-readiness, identity, cache-key, and batching coverage.
- Artifact-only real inference with zero outbound network attempts.

* Follow prune-provided-libs! rename in the embedder plugin build

* Merge nested let in embedder-plugin artifact test

Kondo flagged the inner let as redundant; fold both binding vectors into one.

* Deref the now-delayed core provided-libs in the embedder build

* Bundle MiniLM for in-process complexity scoring

* Activate the embedder plugin in its artifact smoke test

* Activate lazy embedding providers on demand

* Use plugin initializer in artifact smoke test

* Make lazy provider activation best effort

* Log lazy embedding provider activation failures

* Declare the embeddings plugin dependency

* Keep embeddings provider internal

* Activate the in-process embedder during bootstrap

* Fix embedder validation and optional plugin startup

* Declare the embedder plugin as a module

The deps graph only scanned src, enterprise/backend/src, and modules/drivers, so the plugin's
namespaces belonged to no module and anyone linting them got six unresolved-dependency
warnings. Scanning modules/embedder/src too lets the generator record what the plugin actually
takes from the host.

* Move embedder model caching into the weekly cache generator

Review feedback on #78310:
- Drop the redundant persist-credentials: false checkout options.
- Stop writing the model cache from the CI job. The weekly cache-generator
  workflow is the single cache writer; the build job now restores only.
  The downloader is content-addressed (SHA-256 verified, refetches deltas),
  so a stale weekly cache still protects against HuggingFace rate limits
  without affecting correctness.

* Bundle snowflake-arctic-embed-xs instead of Arctic-L

The bundled models are a test tier: they exercise the packaging, DJL
runtime, tokenizer, and pooling machinery without shipping the 1.1 GB
Arctic-L artifacts. xs keeps the same family, query-prefix scheme, and
cls pooling as Arctic-L at 23 MB per architecture. A release-tier build
with the full model comes later, when the plugin is published for
customers or offline use.

At ~90 MB of pinned downloads there is nothing worth caching, so the
cache-generator wiring is reverted and the CI job downloads directly.

* Fail loudly when a catalog entry lacks this architecture's export

resolved-model computed the embedding-space-id from
get-in spec [:architectures arch :sha256] without checking it was
present. A model missing this architecture's export would silently
bake a nil into the identity hash instead of failing, producing a
syntactically valid but bogus embedding-space-id.

* Pin the embedder plugin manifest name to one constant

"Metabase In-Process Embedder" was duplicated in core.clj (the
registered?/load-plugin! lookup key) and the artifact test. A rename
in modules/embedder/resources/metabase/embedder/metabase-plugin.yaml
without updating both call sites would silently disable the plugin,
since registered? just returns false for a name it doesn't recognize.

* Downgrade the hashed-table-name log line to debug

Hashing fires deterministically for every identifier derived from a
long provider/model pairing (e.g. "in-process" + the bundled xs
model), so it was logging a warning on every lookup rather than once
at creation.

* Cover the missing-architecture catalog guard

The error path added alongside the guard had no test, so the earlier
silent nil-hashing behaviour could come back unnoticed.

* Assert the reason on the missing-architecture guard

The message alone leaves `:reason :architecture-not-bundled` untested,
which is the part callers actually branch on.

* Drop the hashed-table-name log line

It fires from a pure name-derivation helper on every lookup. The table
creation it hints at is already logged at info by the caller, and the
parameters behind the hash are in index_metadata.

* Extract the missing-architecture check out of the smoke test

The guard's assertions pushed the smoke test to 155 lines, past the
150-line deftest limit, so Clj-Kondo failed. Pull it into a helper the
way the platform-support checks already are.
2026-08-18 17:11:24 +02:00
Ramiro Nuñez Dosio
c78c9fed48 Update README with new image URL and bug reporting info (#80073)
* Update README with new image URL and bug reporting info

Updated the image URL for the Metabase product screenshot and added a bug reporting guide.

* put metabase-product-screenshot-updated.png to the docs

* fix link in the readme

---------

Co-authored-by: Timur Mashara <timur@metabase.com>
2026-08-18 15:43:18 +02:00
Oleh K
1821975d0c Use Breadcrumb component in Detail View (#79892)
* Use Breadcrumb component in Detail View (GDGT-2596)

Detail View had its own Breadcrumb, duplicating the shared one introduced
in #75114. Point TableNav, ModelNav and CollectionBreadcrumb at
metabase/common/components/Breadcrumb and delete the local copy.

The shared component hardcoded showTooltip={false}, which would have lost
the full-text tooltip Detail View shows on truncated crumbs, so make it a
prop. It defaults to false, leaving every other consumer unchanged.

Crumbs now take the shared component's sizing (0.875em, bold) instead of
inheriting 14px regular; the separator is sized to match so the row stays
consistent. Colour and icons are unchanged.

* Use URL helpers for the Detail View breadcrumb links

TableNav built the database and schema URLs by hand. Extract databaseSlug
from browseDatabase so browseSchemaBySlug can reuse it, then build both
links from the helpers. Both now carry the database slug rather than only
the id.

* Style official collections in the Detail View breadcrumbs

Collection crumbs hardcoded icon="folder", so an official collection
rendered like a regular one. Use CollectionBadge, which resolves the icon
from the collection's authority level, the same way the model page does.

Drop the text-secondary override too: it made Detail View darker than
every other breadcrumb in the app bar.

* Align Detail View breadcrumbs with the rest of the app bar

The detail view nav carried a -13px left margin, putting its first crumb
13px closer to the logo than the collection breadcrumbs shown on every
other page. Drop it so both start at the same offset.

* Match the Detail View breadcrumb separator to the app bar

Its color, font size and line height all differed from the separator the
collection breadcrumbs render. Use the same values, and Box rather than
Text so it inherits the line height instead of setting its own.

* Update the database breadcrumb href expectation

The database crumb now goes through Urls.browseDatabase, which appends
the slug, so the link is /browse/databases/1-sample-database rather than
/browse/databases/1.
2026-08-18 15:39:42 +02:00
Nemanja Glumac
812e970d41 Remove the reduntant skip input from bundle-size step (#80070) 2026-08-18 14:35:31 +02:00
Oleh K
836c398dc7 Make query builder breadcrumb text the same size (#79889)
* Make query builder breadcrumb text the same size (GDGT-2609)

The database and schema crumbs rendered smaller than the rest of the
breadcrumb. HeadBreadcrumbs spread {...rest} after className, so a caller
forwarding className -- even as undefined -- wiped the computed Container
and headVariant classes, and those crumbs fell back to Breadcrumb's
0.875em. Merge the incoming className instead of letting it clobber.

Size the head-variant dividers and the leading icon to match the crumbs
too, so the whole row reads as one line of text.

* Address review comment

Keep the props spread last and exclude className from it, rather than
reordering the spread ahead of className.
2026-08-18 14:35:03 +02:00
Chris Truter
548bae6986 Adopt bare library index tables in dedicated store only (#79986) 2026-08-18 13:00:20 +02:00
Chris Truter
2e45128a8e Drop departed members from the Metabot team (#80031) 2026-08-18 12:22:46 +02:00
lbrdnk
f768659552 Unify build logic in PR env and AI benchmarks (#78534)
* Stub the evals trigger

* use ref arg

* dummy to trigger build

* Clean up

* Use appropriate token

* Simplify pr message

* Use .evals_ref for default ref

* Comment cleanup

* Adjust ref

* Use evals branch

* Use general automation token

* readme for .evals_branch

* Use main of evals for label trigger

* Revert "dummy to trigger build"

This reverts commit e6502c6ff076a47d94eb829b516fc45b37cd7680.

* whitespace

* Address review remarks

* Use ai benchmark naming so we can trigger from pr

* Address review comments

* Remove now redundant branch pinning

* Review remarks

* Enable passing commit to uberjar from other workflows

* Unify ai-benchmark and pr-env build logic

* linter

* checkout action

* separate build step

* Use wip branch

* adjust job output

* Make image building manually triggerable

* tmp comment

* Address review remarks on the build extraction

- ai-benchmark's `image` regained the label guard `uberjar` used to carry; without
  it any label on any same-repo PR built ee+oss and pushed to ECR.
- New `resolve` job turns the dispatch `pr`/`ref` inputs into one full sha, and
  feeds it to both the image build and the eval-run dispatch. `pr` was ignored
  before, so `pr: N` benchmarked the launch branch while reporting against the PR;
  `ref: master` looked for an artifact named after a branch.
- prepare-uberjar-artifact takes a `commit` so the upload key matches what
  check-existing-artifacts/fetch-artifact look up. Without it a build for an
  explicitly passed commit uploaded under the event sha and then failed to
  retrieve itself.
- uberjar's build checkout honours an explicit commit, but stays on the event ref
  otherwise so PR builds keep compiling the merge ref.
- Concurrency group includes the calling workflow, so a re-label on one caller no
  longer evicts the other's queued run.
- ECR existence check targets the pushed registry explicitly and no longer hides
  real errors behind "not found".
- Scoped `secrets: inherit` down to the two secrets dockerize needs, added
  read-only default permissions, dropped the dead `jar xf`, restored the comment
  explaining `finalize`, and reset EVALS_BRANCH to main.

* Point smoke evals back at the evals-docker-registry branch

Temporary while metabase/evals#32 is unmerged — the ECR image logic is only on
that branch. TODO comment retained so this cannot merge as-is.

* Align ai-benchmark pr/ref precedence with evals eval-run

eval-run.yml resolves both inputs with `ref` overriding the PR head; this
resolved `pr` first, so the same two inputs meant opposite things either side of
the dispatch. Flip precedence and the run-name to match, and point the `ref`
description at eval-run's containment rule instead of claiming pr wins.

* Cleanup the uberjar worfklow

* Smoke should be triggered on !cancelled()

* Reset pointer to evals branch
2026-08-18 12:14:46 +02:00
bryan
7b9b376765 Harden JSON-encoded parameter-values request schema (#80035) 2026-08-18 09:52:00 +01:00
Romeo Van Snick
3933c11183 Register visualization definitions eagerly, load chart components on demand (#79745)
* Give List, Map, ObjectDetail and PivotTable their own definition file

* Let a visualization register a loader for its component

* Load every chart component on demand

* Load the chart chunk while the query is still running

* Delete the echarts lazy boundary the chart split made redundant

* Dedupe modules shared by the SDK's async chunks (#79748)

The chunked SDK entry's splitChunks filter only matched chunks whose
name starts with "embedding-sdk-chunk", so the unnamed chunks created by
import() were excluded from splitChunks entirely. With every chart now
loaded through import(), each echarts-based chart chunk bundled its own
copy of echarts, zrender and the shared viz code, growing the reachable
SDK total from 3.3MB to 6.2MB gzipped.

Include async chunks in the filter and add an asyncCommons cache group
(minChunks: 2) so shared modules move into a common async chunk. The
vendors group keeps its own name-based filter so async modules are not
merged into the eagerly loaded vendor chunks. Initial payload is
unchanged; the legacy monolith and the bootstrap stay excluded.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Lower the size floor for the SDK's shared async chunks

* Load chart components before a story renders

* Retry a chart chunk that fails to download

* Keep one loading state while a chart chunk loads

* Delete the leaflet and pivot-table lazy boundaries the chart split made redundant

* Say why onInit is late now that the renderer is not lazy

* Register the charts a test renders before it runs

---------

Co-authored-by: Nicolò Pretto <info@npretto.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 11:21:10 +10:00
Fraser
8dbbb58c57 Create current-user module (#79357) 2026-08-18 12:58:58 +12:00
Romeo Van Snick
e7de47b608 Module boundaries follow-ups: storybook element order, dead window-bridge rule (#79803)
* Order the storybook config element before embedding-sdk-shared

* Drop the dead window-bridge whitelist rule
2026-08-18 10:25:04 +10:00
Anderson
f6e9cd0a58 Adapt the collection bulk action bar to pinned, unpinned, and mixed selections (#79857)
* Adapt the collection bulk action bar to pinned, unpinned, and mixed selections

* Align the bulk action bar per-state actions with the Figma spec

* Show the bulk action overflow for unpinned selections and fix its trigger styling

* Move bulk action logic into UnarchivedBulkActions and address review feedback

* Center the bulk action overflow trigger icon via a label class

* Make BulkActionButton code nicer

* Address review comments
2026-08-17 23:38:59 +00:00
Romeo Van Snick
c883033bb6 Make the provider props store payload opaque (#79801) 2026-08-18 09:37:11 +10:00
Cam Saul
34863099c0 Migrate MongoDB to MBQL 5 (#77673)
* [MongoDB] remove `:metadata/key-constraints` driver feature flag (incorrectly set)

* Migrate MongoDB to MBQL 5 [WIP]

* cljfmt

* Test fix 🔧

* Test fixes 🔧

* Test fixes 🔧

* Test fixes 🔧

* value.clj => value.cljs

* value.cljs => value.cljc 🤦

* 🔧

* Let's not deprecate too many things in this PR

* Fix more stuff? 🔧

* Test fixes 🔧

* Revert SQL driver changes

* Test fixes 🔧

* cljfmt

* Test fix 🔧

* Kondo (partial)

* Test fixes 🔧

* cljfmt

* kondo

* Address PR feedback 🔧

* Update ratchets 🔧

* 🔧

* 🔧

* Test fix 🔧

* Update ratchets.edn
2026-08-17 15:34:42 -07:00
Ryan Laurie
fbfa5c9c40 Use ubuntu slim image for 59 little jobs (#80028)
use ubuntu slim image for 59 little jobs
2026-08-17 22:18:37 +00:00
Denis Berezin
7cd842e414 UXW-5013 Support adding a metric to recent items (#79661)
* UXW-5013 Support adding a metric to recent items

Co-authored-by: Ruchir D <111691121+Ruchir-D@users.noreply.github.com>

* Code review fixes

---------

Co-authored-by: Ruchir D <111691121+Ruchir-D@users.noreply.github.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-18 01:09:23 +03:00
Nemanja Glumac
3fdbcf9ac6 Delete minimal-tests workflow (#80029) 2026-08-17 23:40:16 +02:00
github-automation-metabase
e791ef41da Auto-update documentation for master (#79919)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-17 19:48:45 +00:00
github-automation-metabase
68496eb7a0 Auto-update documentation for master (#79847)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-17 15:48:30 -04:00
Nicola Mometto
af35f7f0af Backend hardening (#80003)
Co-authored-by: Alexander Polyankin <alexander.polyankin@metabase.com>
Co-authored-by: Alexander Lesnenko <alxnddr@users.noreply.github.com>
Co-authored-by: Ben Grabow <ben.grabow@gmail.com>
Co-authored-by: Bryan Maass <bryan.maass@gmail.com>
Co-authored-by: Dan Sutton <dan@dpsutton.com>
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
Co-authored-by: Timothy Dean <timothy@timothydean.dev>
Co-authored-by: Tyler Plude <tsplude@gmail.com>
2026-08-17 13:44:13 -06:00
technomancy
c7166aaa15 Only run latest app db variants on PRs (#78755)
* Don't test MySQL/MariaDB AppDB on anything but latest outside release/master.

* generate matrix up front

# Conflicts:
#	.github/workflows/app-db.yml

* bugfix

* parameterize it all

* make things more uniform

---------

Co-authored-by: Ryan Laurie <iethree@gmail.com>
2026-08-17 13:40:56 -06:00
Jeff Bruemmer
8f7a033678 docs - map tile notes (#80006)
* map tile notes

* edit

* image
2026-08-17 19:35:23 +00:00
Ben Grabow
6d4769584e Use an isolated executor pool for test isolation (#79913)
* Use an isolated executor pool for test isolation

* Fix another test

* Fix some other likely flaky tests

* Consistently use the mock executor in the tests that need isolated behavior.
2026-08-17 14:10:21 -05:00
Chris Vandoodewaard
a68d9424ca disable CodeQL overlay DB cache (#79987)
* disable cache

* cleanup codeql cleanup workflow
2026-08-17 14:35:46 -04:00
github-automation-metabase
7582bb77ec Update translations 2026-08-17 (#79988)
* Update translation sources 2026-08-17

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-08-17 17:42:59 +00:00
Ben Grabow
a0d8a1758b Check via .getDataset instead of .listDatasets when all datasets are exactly named (#79921)
* Check via .getDataset instead of .listDatasets when all datasets are exactly named

* Use perf/every?

* Handle some corner case differences between .listDatasets and .getDataset method behaviors
2026-08-17 11:40:08 -05:00
Kamil Mielnik
7470cf2485 GDGT-2646 Refresh leads to custom viz reverting to standard viz (#79888) 2026-08-17 20:28:55 +07:00
Michael Eby
c1fc823ce5 docs: callout for AI Analytics Week (#79918) 2026-08-17 09:18:22 -04:00
Chris Vandoodewaard
b0ba105d0c add file, not line number to .po files (#79723)
add file, not line number
2026-08-17 09:03:55 -04:00
Nick Fitzpatrick
e49a6d6754 Uxw 4937 combine account password and security tabs (#79677)
* Moving Account Security Panel

* tests

* adjustments

* update docs

* Product Feedback, rebasing adjustments

* fix unit test

* adjust e2e
2026-08-17 09:18:28 -03:00
Stas Gavrylov
6b311219dc Fix duplicated rows on the Erroring questions page (#79520)
The `latest_qe` CTE joined `query_execution` to its per-card max
`started_at`, which returns every execution tied at that timestamp
rather than one. A card whose two most recent runs landed in the same
clock tick therefore fanned out into two identical rows on
`/monitor/errors` — same card_id, duplicate row keys in the table, and
an inflated `total_count`. Rank the tied executions and keep one.

Also give the ORDER BY a unique tiebreaker: no sort column is unique,
so tied cards were ordered arbitrarily and could repeat on one page of
LIMIT/OFFSET results while going missing from another.
2026-08-17 15:07:21 +03:00
Oleh K
8129fff836 Match SelectButton styling to Select (#79879)
* Update SelectButton styling to match Select (#GDGT-2705)

SelectButton diverged from the new Mantine Select: bolder text, a smaller
lighter chevron, a taller radius and, on long values, wrapping to two
centered lines instead of truncating.

Match the md Select input on every measurable property — 40px height, 4px
radius, regular weight, left aligned, 16px chevron in text-primary, and
Select's disabled tokens — and truncate long values with an ellipsis. The
clear affordance now sits alongside the chevron rather than replacing it,
matching Select's layout.

Migrate the component from Emotion to CSS modules while here.

Also fixes three Select issues surfaced by comparing the two side by side:

- long values cut off mid-glyph instead of ellipsizing
- with a clear button, the value ran underneath it, because the right-hand
  padding was pinned to a flat 40px while Mantine widens that section to
  60px (md) / 41px (xs)
- the disabled right section painted an opaque background over the field's
  translucent disabled fill, showing a square-cornered box on any
  non-white surface

* Remove comment

* Address review comments

- use !! instead of a null check, onClear is never null
- drop size={16} on both icons, it is Icon's default
- drop overflow: hidden from .SelectInput; the input already computes
  overflow: clip, so the declaration never applied and the ellipsis works
  without it
- cover sm, lg and xl in the clear-button padding fix, not just md and xs
2026-08-17 13:01:49 +02:00
Romeo Van Snick
0b0e6c0f4b Delete the unread collections entity slice (#79870)
* Stop hydrating the collections entity slice

* Delete the unread collections entity slice

* Drop the orphaned createMockEntitiesState import

* Wait for the collection query in the TableCollection link test

* Await the collection tree in the SavedEntityPicker tests
2026-08-17 20:55:14 +10:00
Ngoc Khuat
2366d9fc6b Don't let a failed advisory matching query override the version check (#79466)
* security center: don't let a failed matching query override the version check

an advisory whose matching query names a table the instance doesn't have yet
fails at parse time, and we mapped that to :error. the notification task treats
:error the same as :active, so instances that the version range had already
ruled out got notified anyway. that's how SC-2026-015 reached 1.58.

affected means in-range AND matched, so an undeterminable query result shouldn't
outrank a version check that already said no. same as SQL giving FALSE AND NULL
= FALSE. in-range instances with a query we genuinely couldn't run still report
:error.

* security center: split evaluate-advisory!-test to stay under the kondo length limit
2026-08-17 09:53:42 +00:00
Romeo Van Snick
3a065165fb Read database, table, and field display data from RTK Query (#79810)
* Read database and field display data from RTK Query, not state.entities

* Read table and database breadcrumb data from RTK Query, not state.entities

* Delete the legacy useTableQuery and useDatabaseQuery entity hooks

* Delete the legacy useDatabaseListQuery entity hook

* Keep FileUploadStatus on the mirror-backed table hook
2026-08-17 18:39:07 +10:00
Romeo Van Snick
c9c8b101cd Import only the metadata actions the reference module uses (#79869)
* Drop the dead metadata action spreads in the reference module

* Import only the metadata actions the reference module uses
2026-08-17 13:52:33 +10:00
Fraser
fe94806a72 Re-tier metabot to platform P4 and detail-view to P2 (#79866) 2026-08-17 12:40:31 +12:00
Fraser
a9ad0ee017 Replace the custom-homepage cache patch with tag invalidation (#79867) 2026-08-17 10:38:23 +12:00
metamben
47e50180d6 Return nil when cached query is canceled (like non-cached queries) (#79625)
closes QUE2-106
2026-08-16 15:33:32 +03:00
Romeo Van Snick
45dfb5a837 Make the theme editor an app-tier module (#79802) 2026-08-15 09:21:36 +10:00
Egor Grushin
eb009eb8f9 Require selecting an alert schedule time (#79623)
* Refactoring Schedule, force a user to pick an hour in alerts and subscriptions
2026-08-14 23:54:58 +01:00
Michael Eby
f258c5de52 docs: update outdated starter pricing (#79890) 2026-08-14 17:04:45 -04:00
appleby
fb3f08713d Gemini Enterprise Agent Platform: FE changes (#79558)
* Add Gemini Enterprise Agent Platform provider settings

* Invalidate the cached models list when credentials change

A credential write can change which models the provider serves, e.g. a different Bedrock region, Google location, or
Azure resource.

* Give the FileInput placeholder and clear button a visible color

The theme resolves these to transparent making them invisible withouth the override.

* Review feedback: whitelist recommended models

* Fix useUpdateMetabotSettingsMutation import

* Add gemini-3.7-flash to list of recommended models
2026-08-14 12:31:07 -07:00
appleby
c6e544cb1c Gemini Agent Platform: BE support Gemini models (#78193)
* Add llm-google-* settings for the Gemini Enterprise Agent Platform

The location, project ID and model become path segments of every request, so each is validated as URL-safe.

* Wire the google provider into Metabot provider settings

* Add Google Gemini provider via streamGenerateContent API

Add new provider supporting Gemini models via the Gemini Enterprise Agent Platform

- New ns `metabase.metabot.self.google`. Handles generic provider concerns like
  - contructing appropriate endpoint urls for the given model and location
  - validating caller-provided credentials via a countToken call
  - credentials handling
  - validating config
  - remapping upstream errors with improved error messages
  - toplevel request entrypoint
- New ns `metabase.metabot.self.google.stream-generate-content`
  - handles wire format and aisdk chunk tranlation for Google's streamGenerateContent API

* Wire Google credentials into the admin API

Optional credential settings that are set by an env var no longer block connect or disconnect.

* Skip the provider setting write when shadowed by an env var

* Drop the unused llm-bedrock-configured? setting

Nothing read it: the frontend never referenced it, and its last backend caller went away when
configured-provider-credentials switched to provider-credentials-complete? for the bedrock branch.

* Extract the shared tool->function conversion into schema/tool-function

The mu/defn doc-stripping + filter-schema-by-features + JSON Schema transform boilerplate was copy-pasted across the
claude, openai, chat-completions, and google adapters.

* Review feedback: allow settings to be cleared even if env shadowed

There was a mismatch where check-not-env-shadowed! allowed nil values but set-setting-unless-env-shadowed! did
not. Update set-setting-unless-env-shadowed! to preform nil writes so that a disconnect fully clears the appdb
settings even if some are env shadowed.
2026-08-14 12:31:07 -07:00
technomancy
1ca1c69b3f Track datasets created in with-dynamic-table. (#79862)
Update snowflake's sql.tx/qualified-name-components to not ignore db-name.
2026-08-14 11:43:40 -07:00
Chris Vandoodewaard
6c7ddff080 add workflow to run security scans daily (#79843)
* add workflow to run security scanners

* add newline

* add slack notification on failure
2026-08-14 14:29:21 -04:00
Chris Vandoodewaard
0d1f7d911c used cache deps for Mage (#79903) 2026-08-14 17:35:42 +00:00
Andrei Simionescu
0e70b3eb71 BOT-1956: Read the Python library source from :source (#79573)
Change `get_transform_python_library_details` to read from `:source` instead
of `:content` to match what the API sends. The code is wrapped in a Markdown
fence longer than any backtick run inside it, prefixed with a line telling
the model to treat it as data, and cut off at 100k characters with a
truncation marker when the library is bigger than that.
2026-08-14 18:22:52 +01:00
Andrei Chernykh
0c9e3642c9 Convert enterprise embedding dataselector to ts (#79789)
* Convert enterprise embedding DataSelector to TypeScript

* Replace default exports with named exports in the embedding data picker
2026-08-14 10:00:49 -07:00
Ben Grabow
e3708068c3 Count only the query executions under test (#79726) 2026-08-14 11:37:09 -05:00
Nathan Voxland
5dbc8db1d2 Only default warehouse-allowed-networks to external-only on prod (#79672) 2026-08-14 11:17:55 -05:00
Andrei Chernykh
cab11e6bcc chore: Convert dashboard click drill to ts (#79626)
* Rename dashboard-click-drill and its unit spec to TypeScript

* Convert dashboard-click-drill to TypeScript

* Address review feedback: discriminate click-behavior types and replace non-null assertions with checkNotNull

* Deduplicate base intersections in click-behavior union types
2026-08-14 09:02:00 -07:00
Kevin Kelleher
f0f11536f6 Avoid rerendering visualization on every SQL editor keystroke (#79825)
* Avoid rerendering visualization on every SQL editor keystroke

* fix unnecessary `card` assignment

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2026-08-14 15:41:27 +00:00
Riley Thompson
33ac781b25 Snowflake passphrase protected private keys (#79542)
* update tests

* passphrase protected keys

* clean up

* improve tests
2026-08-14 15:36:29 +00:00
Michael Eby
4ba5c162e5 docs: add metric dimensions page (#79772)
* docs: add metric dimensions page

* Optimized 3 images, saved 262.3 KB (53.0% smaller)

* Edits from Jeff

* more consolidation

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-14 11:05:06 -04:00
Ben Grabow
e29527e5b3 Assert only on password reset email count (#79721)
* Assert only on password reset email count

* Initialize :db :web-server :test-users once for support access grant session tests
2026-08-14 08:52:55 -05:00
Chris Vandoodewaard
c980d85154 print out basic summary for release workflow (#79844) 2026-08-14 08:55:16 -04:00
Chris Vandoodewaard
19899d290d cache Babashka, also guard cache generation (#79841)
* cache Babashka, also guard cache generation

* fix linting

* also cache whitespace-linter deps
2026-08-14 08:54:13 -04:00
Kamil Mielnik
22e69fce8b GDGT-3076 Toast notifications are truncated for unavailable visualizations (#79766) 2026-08-14 09:45:18 +00:00
Oleh K
0a42bfbac2 Keep the remove-table button in the row when table names are long (#79760)
* Keep remove-table button in the row when table names are long

The table selector laid its label button and remove button out in a Group
that wraps by default. The label button is a flex item, so it kept the
default min-width: auto and refused to shrink below the width of its
longest line. A long database or schema name made it claim the whole row
and pushed the remove button onto a line of its own, at the left edge.

Stop the group wrapping, let the button shrink, and truncate the label
lines instead. The lines use max-width rather than width so a short line
keeps the left alignment the Stack gives it -- Button centers its label,
so a full-width line would be centered.

Also space the two label lines apart slightly.

* Space the remove button with margin instead of padding

Padding right sat inside the ActionIcon, so its hover background ran past
the icon and the glyph sat off centre. Margin keeps the same gap from the
row edge with a symmetric 28x28 hit area.

* Use Ellipsified for the table selector label lines

Manual truncation clipped the text with no way to read the full name.
Ellipsified brings the overflow styles and a tooltip that appears only
when the text is actually cut.

Dropping the Stack's align=start matters here: with flex-start each line
sized to its max-content width, so it never overflowed itself and the
truncation check never fired -- the button label clipped the text
instead, without an ellipsis. Stretched lines re-expose the button's
centered text-align, hence ta=left.

* Drop no-op layout props from the table selector

The button root class duplicated miw={0}, which already lands as an
inline min-width. The Stack's w/miw/justify did nothing: it stretches to
the button label by default, the label already carries min-width 0, and
its height is content-driven. ActionIcon has min-width equal to its
natural size, so it cannot shrink and flex="0 0 auto" changed nothing.

Verified by removing each and re-measuring at 800/1440/2560px.

* Drop redundant truncation styles and the empty-state Ellipsified

Mantine's button label already sets overflow hidden, and Ellipsified
carries its own min-width 0, so both declarations restated what was
already there. The css module is back to its state before this branch.

"Select a table..." is a fixed short string that never truncates, so it
goes back to a plain Box.
2026-08-14 10:04:29 +02:00
Anderson
32a179075c Add Escape and Delete shortcuts for collection item selection (#79670)
* Add collection selection keyboard shortcuts

* Address review comments
2026-08-14 00:32:36 -03:00
Fraser
acaeecd506 Delete dead ai-sql-fixer types, move revision diff guards into querying (#79865) 2026-08-14 13:24:19 +12:00
Fraser
fbb89ab47a Delete dead SandboxedPortal and unused formatting barrel exports (#79864) 2026-08-14 13:24:07 +12:00
Romeo Van Snick
8f06a28894 Compare a PR's bundle size against the base ref its uberjar was merged with (#79858) 2026-08-14 11:23:49 +10:00
Fraser
5f6f2ba892 Move card-embed editor extensions into documents (#79695) 2026-08-14 12:39:37 +12:00
Ben Grabow
c5e7d8024a Fix flaky trigger metadata sync test (#79771)
* Isolate async task execution from other tests and ensure task is cleaned up

* Use dynamic redefs
2026-08-13 17:20:02 -05:00
Andrei Chernykh
e0342c82dd Bump react-router from 7.18.1 to 7.18.2: CVE-2026-22030 (#79838) 2026-08-13 14:53:25 -07:00
Maz Ameli
662e74ba16 Add Data Studio guide page and reorganize sidebar navigation (#76707)
* Add Data Studio guide page and reorganize sidebar navigation (#76707)

* adjustments

* transform tabs, e2e adjustments

* oss test

* redirect first timers to the guide page and clean things up

* PR Feedback

* OSS copy, re-adding tests

---------

Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
2026-08-13 17:32:21 -03:00
Ryan Laurie
87dc99836a Auto-update documentation for master (#79792)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-13 19:22:26 +00:00
Alex Yarosh
f5e7923fb5 docs: yet more AI providers (#79395)
* Revise AI provider details in settings documentation

Updated AI provider list to include Mistral and Z.AI models.

* Update docs/ai/settings.md

Co-authored-by: Michael Eby <38187276+michaeleby1@users.noreply.github.com>

---------

Co-authored-by: Michael Eby <38187276+michaeleby1@users.noreply.github.com>
2026-08-13 18:23:39 +00:00
Riley Thompson
0218df7cd7 Offset window function support for clickhouse (#79541)
* backwards compatible version

* clean up

* remove check
2026-08-13 18:06:33 +00:00
Andrei Simionescu
28d47ffafe BOT-1956: Render MBQL transform sources as portable-repr JSON for Metabot (#79575)
Notebook-built transform sources now reach Metabot as the same portable JSON that `construct_notebook_query` consumes, with tables and fields by name, while native SQL still passes through verbatim. Card entity IDs inside an exported query now come from the permission-checked content store, so a source card the reader can't open renders no query at all instead of falling back to an EDN dump.
2026-08-13 18:25:29 +01:00
Michael Eby
f9ac58f586 docs: add Monitor docs section (#79577)
* docs: add Monitor docs section

* update inbound links for profiling-metabase move

* tiny edits

* docs - CLI analytics (#79551)

* edits from jeff

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-08-13 12:58:41 -04:00
Nick Fitzpatrick
93fb2a5515 Chart Settings Popover horizontal margin (#79786) 2026-08-13 13:58:07 -03:00
Phoomparin Mano
633781cc4e Replace flaky embed code e2e test with jest tests (#79818)
* replace e2e with unit tests

* Update frontend/src/metabase/embedding/embedding-iframe-sdk-setup/components/tests/SdkIframeEmbedSetup.unit.spec.tsx

Co-authored-by: Nicolò Pretto <info@npretto.com>

* remove embed-snippet unit test

---------

Co-authored-by: Nicolò Pretto <info@npretto.com>
2026-08-13 16:30:57 +00:00
YB
98ea8d0a0a [GDGT-3087] mount /api/email routes with auth middleware (#79807)
mount /api/email routes with +auth middleware
2026-08-14 00:23:24 +08:00
Denis Berezin
5efdec98b5 UXW-4961 Convert data-grid to TS (#79788)
* UXW-4961 Convert data-grid to TS

* review fixes
2026-08-13 19:19:37 +03:00
Chris Vandoodewaard
f40f61f6ee add Mage deps to the cache (#79787)
resolve mage deps
2026-08-13 08:08:28 -04:00
Fraser
4724b7bde4 Split viz registry from the index file (#79682) 2026-08-13 23:16:30 +12:00
Fraser
9bed0e2f99 Move the query-action plug to querying and re-tier actions to platform (#79428) 2026-08-13 22:50:14 +12:00
Fraser
1be7ee6d1b Move metabot's endpoints into the metabot module (#79563) 2026-08-13 22:39:12 +12:00
Romeo Van Snick
21600b34b2 Make the metabot state channel payload opaque (#79800)
* Make the metabot state channel payload opaque

* Make MetabotStateChannel generic over the state type
2026-08-13 20:09:59 +10:00
Romeo Van Snick
1f6a8476dc Move the SDK bundle window declaration next to its exports type (#79799) 2026-08-13 20:04:13 +10:00
Romeo Van Snick
aa1d39ebc4 Move SDK storybook themes into embedding-sdk-shared (#79796) 2026-08-13 20:03:51 +10:00
Fraser
644d271c6d Sever visualizer, custom-viz and data-grid edges (#79355)
* Invert visualizer click-object formatting via a Visualization prop

Visualization no longer imports formatVisualizerClickObject from the
visualizer module; DashCardVisualization supplies a transformClickObject
callback instead, so the dashboard side owns the visualizer coupling.
2026-08-13 21:51:41 +12:00
Oleksandr Yakushev
8a83354b64 [perf] Optimize Malli validator cache (#79274) 2026-08-13 08:56:22 +00:00
Fraser
59646dd4b0 Move query-endpoints into querying (#79564) 2026-08-13 20:49:27 +12:00
Fraser
b249fe1b3d Move admin and single-consumer endpoints out of api (#79354) 2026-08-13 20:48:53 +12:00
Romeo Van Snick
a021efac20 Give the transforms, Data Studio, monitor and account pages their own chunks (#79701)
* Give the transforms and Data Studio pages their own chunks

* Give the monitor and account pages their own chunks

* Load the transform pages as one chunk
2026-08-13 15:49:38 +10:00
Romeo Van Snick
7a8ce63393 Give the chart-rendering feature pages their own chunks (#79700)
* Give the chart-rendering feature pages their own chunks

* Load the metric pages as one chunk

* Fetch the Data Studio Library metric pages when their route is matched

* Load the metric pages and their Data Studio wrappers as one chunk

* Say why the metric wrappers share the core chunk
2026-08-13 15:04:30 +10:00
Romeo Van Snick
917c65d41f Remove the unused ?source SVG imports from the icon map (#79741) 2026-08-13 15:01:13 +10:00
Romeo Van Snick
7d228d6fbe Move builtin auth plugin registration into the auth module (#79798) 2026-08-13 13:09:08 +10:00
Romeo Van Snick
fb8cf2d724 Move NewItemMenu integration spec next to NewModals (#79797)
* Move NewItemMenu+NewModals integration spec next to NewModals

* Drop the stale NewItemMenu spec pattern from module boundaries
2026-08-13 13:09:04 +10:00
Romeo Van Snick
a5268784d3 Move customVizColumnTypes into enterprise custom_viz (#79795) 2026-08-13 13:07:35 +10:00
Romeo Van Snick
033ca3a319 Give the dashboard its own chunk (#79645)
* Split the app shell's dashboard selectors into their own module

* Declare the dashboard card actions beside their type constants

* Keep the dashboard reducer off the parameter and data-fetching actions

* Split the tab thunks off the tab reducer

* Fetch the embed setup modals when one is opened

* Give the dashboard its own chunk

* Keep the query builder URL sync from replacing a pending navigation

* Keep the table picker's automatic selection off a pending navigation

* Read the pending navigation from the router, not from a render

* Wait for the code-split embed modal before probing for its terms card

* Drop the note restating what import type already says

* Outrank the grid placeholder style the dashboard chunk now loads later
2026-08-13 13:05:25 +10:00
Anderson
e406a40c47 Reveal column descriptions on hover in the Detail visualization (#78875)
* Show column descriptions in the Detail visualization via an info icon tooltip

* Reveal the Detail column description icon on hover instead of always

* Reserve the description icon's width so hovering never shifts the Detail table

* Address review comments

* Remove delay showing info icon
2026-08-12 23:56:09 -03:00
Anderson
c9f16511fa Allow selecting collection items with shift+click and a Select menu (#79663)
* Add a shift-pressed hook and shift+click selection for rows and pinned cards

* Add a Select entry to the collection item overflow menu

* Cover shift+click and the Select menu entry with an e2e test

* Keep shift+click on row actions from selecting the row

* Simplify collection item selection callbacks

* Address collection selection review feedback

* Scope pinned card queries now that pinned items are listed as rows

A pinned item renders both as a card and as a table row, so unscoped link
queries by item name now match twice. Scope them to the pinned section and
wait for the pinned query before asserting.

* Address review comments
2026-08-12 22:57:14 -03:00
Nemanja Glumac
5147989def Run a configured-skip driver when its own files change (#79793)
Drop the quarantine-conflict flag: a driver marked `skip` in ci-test-config
whose own `modules/drivers/*` files changed now simply runs, instead of
spinning up a runner that fails immediately and demands a `ci:run-<driver>`
label. Changes to another driver's files, or to shared code, leave the skip
in place.

Rename the decision logic's "quarantine" vocabulary to "skip", matching the
config's own `status: skip`. "Quarantine" now refers only to ci-conductor's
suite-level gating rules.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:34:15 +00:00
Fraser
c917cb51a5 Reduce UI imports from viz-core (#79684) 2026-08-13 10:30:07 +12:00
Fraser
d7a8cb8f84 Resolve settings widgets by name (#79683) 2026-08-13 10:29:31 +12:00
Anderson
32d8cbe5d8 Allow pinned collection items to be selected alongside table rows (#79614)
* Make useListSelect key-safe and pinned cards selectable

* Cover shared pinned and table selection with tests

Adds a CollectionContent selection harness and end-to-end coverage for
selecting the same item from its pinned card and its table row, and points
the pinned item menu helper at the card element so it still resolves once
the card becomes a selection target rather than a link.

* Swap the pinned card icon for a checkbox on hover in select mode

* Reset pinned card hover state outside select mode

* Target projected metric dataset requests in e2e

* Simplify pinned selection and select-all handling

* Simplify pinned selection inputs

* Share one selection entry between pinned cards and table rows

Pinned items now appear in the collection contents list (#79679), so the
pinned grid and the table render the same logical item twice. Both read and
write the single useListSelect entry keyed by id plus model, and select-all
covers the listed page once instead of composing a separate pinned set.

* Simplify selected pinned card styling
2026-08-12 19:17:34 -03:00
Nemanja Glumac
b4366d39b7 Consolidate driver ci labels under ci:run-<driver> (#79668)
* consolidate driver CI labels under ci:run-<driver>

break-quarantine-<driver> and ci:run-<driver> were two spellings of the
same override: ci:run-* is checked at Priority 3, ahead of the quarantine
check, so it already forces a quarantined driver to run.

Drop the break-quarantine-* family and point the quarantine-conflict hint,
the mage help, and the workflow error messages at ci:run-<driver>.

* rename ci:all-cloud-drivers to ci:run-all-cloud-drivers

Every other driver force-run label is spelled ci:run-*.


Co-authored-by: Chris Vandoodewaard <chris.vandoodewaard@metabase.com>
2026-08-12 22:38:11 +02:00
Sloan Sparger
d678585e39 [Metabot] Handle expired sessions (#79742)
fix
2026-08-12 12:51:04 -05:00
Sloan Sparger
cb5adad43b [Metabot] Better scrolling on historical convo load (#79734) 2026-08-12 17:49:21 +00:00
Sloan Sparger
ab6756d28f [Metabot] Hide Metabot copy action for textless responses (#79727) 2026-08-12 12:48:26 -05:00
Chris Vandoodewaard
7b8de67c1c only run milestone workflows in metabase/metabase (#79767)
* guard all milestone workflows so they only run in metabase/metabase

* add guards/notes for other workflows doing milestone things

* format

* typo

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* another typo

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-12 16:26:10 +00:00
Ben Grabow
b59cd74dc8 Fix flaky cleanup of poll thread (#79729) 2026-08-12 10:59:27 -05:00
Andrei Chernykh
b0826c98f3 Convert app.js and app-main.js entry points to TypeScript (#79724)
* Convert app.js and app-main.js entry points to TypeScript

* Throw instead of logging when the root element is missing
2026-08-12 08:55:03 -07:00
Andrei Chernykh
893c614bed Convert metabase-lib/v1 parameter-values utils to TypeScript (#79555)
* Convert metabase-lib/v1 parameter-values utils to TypeScript

* Address review feedback: keep falsy values in conjunct, flatten test describes
2026-08-12 08:54:03 -07:00
Andrei Chernykh
6580d35948 chore: Convert schema.js to TypeScript (#79685)
* Convert schema.js to TypeScript

* Shorten comments

* Remove dead CacheConfigSchema

* Replace db_id cast with checkNotNull
2026-08-12 08:50:33 -07:00
Chris Vandoodewaard
3e231408dd add repo to release notification (#79770) 2026-08-12 11:37:16 -04:00
Sloan Sparger
a33d96bbd3 [Metabot] Handle content-filter finish reason (#79336)
handle content-filter finish reason
2026-08-12 10:15:31 -05:00
Sloan Sparger
7514efdd2a [Metabot] Handle length finish reason (#79335)
* support length finish reason at the api level

* add length stop reason ui

* TEMP COMMIT

* sure up new providers

* remove trailing whitespace
2026-08-12 10:15:31 -05:00
Jeff Bruemmer
b8e6d94a47 docs - impersonation notes (#79768) 2026-08-12 14:17:57 +00:00
Oleh K
1944ddd2c7 Stop the alert run-status icon from shrinking (#79747)
* Stop the alert run-status icon from shrinking

The warning icon sits in a flex row next to the run's error message, and
with flex-shrink at its default of 1 a long message squeezed it down to a
few pixels wide while its height stayed at 16px.

* Use FixedSizeIcon for the alert run-status icon
2026-08-12 14:22:24 +02:00
Andrei Simionescu
c30815f910 BOT-1921: Document additional Metabot environment variables (#79662)
Ten Metabot settings were marked :doc false, excluding them from
environment-variables.md. Remove the flag and write real descriptions
so MB_METABOT_NAME, MB_METABOT_ICON, MB_METABOT_SHOW_ILLUSTRATIONS,
the three system prompt settings, and the four EE usage-limit/
permissions settings show up in the generated docs.
2026-08-12 10:57:08 +01:00
Romeo Van Snick
08beefc835 Give the admin section its own chunks (#79643)
* Give the admin section its own chunks

* Load the admin pages as two chunks

* Fetch the tenant people and group pages when their route is matched

* Fetch the permissions pages when their route is matched

* Export AdminApp and DataPermissionsPage by name

* Say why the tenant wrappers get their own chunk
2026-08-12 13:57:40 +10:00
Fraser
74b08a51c8 Update module-boundaries config (#79733) 2026-08-12 14:33:34 +12:00
Fraser
244700d893 Create shared value-formatting module (#77771) 2026-08-12 13:19:22 +12:00
Romeo Van Snick
2a3f535c39 Prefetch a code-split page when a link to it scrolls into view (#79688) 2026-08-12 10:39:11 +10:00
Denis Berezin
eebccaad10 UXW-4973 Correctly show cache durations (#79499)
* UXW-4973 Correctly show cache durations

* Add validation

* Review fixes

* Fix CI

* Review fixes

* Fix e2e test
2026-08-12 02:09:14 +03:00
Andrei Chernykh
3b936768f0 Show pinned items in the collection contents list (#79679)
* Show pinned items in the collection contents list

* Update e2e specs for pinned items appearing in the contents list

* Simplify the collection empty state offset to a plain vh value

* Simplify bookmark state, skip the pinned query in trash, and drop the dead toolbar prop

* Use a real pointer click for the pinned item menu so hover cannot be raced away
2026-08-11 16:00:16 -07:00
Kevin Kelleher
1d56ea8657 Use consistent icon for explorations (#79728)
use consistent icon for explorations
2026-08-11 21:58:28 +00:00
Kevin Kelleher
4981a05a19 Include library in Entity and Mini Pickers after downgrade to OSS (#79678)
include library in Entity and Mini pickers after downgrade to OSS
2026-08-11 17:38:30 -04:00
Mihael Konjević
bd8bab1d36 fix: run source-card field-type inference before cross-stage inference (BOT-1604) (#78799)
* fix: run source-card field-type inference before cross-stage inference (BOT-1604)

The repair pipeline mini-resolves earlier stages to type a later stage's
cross-stage field refs, but ran that pass before stamping base-type onto a
source-card stage's own bare-name field refs. The mini-resolve of a
source-card-sourced prefix then always failed schema validation and was
silently skipped, leaving cross-stage refs untyped and the query permanently
unrunnable for any multi-stage construct_notebook_query built on a saved
question/model.

* docs: fix stale pass numbering in repair pipeline docstring (BOT-1604)

Number the new source-card field-type pass 4.5 and keep the pre-existing
cross-stage pass as 5, instead of bumping cross-stage to 5.5 - this leaves
the other in-file references to "Pass 5" correct and matches the file's
existing fractional-pass-number convention.
2026-08-11 23:28:22 +02:00
Mihael Konjević
56117f5bb8 Surface metric explicit-join dimensions via agent feedback (BOT-1612) (#74989)
- metric-details surfaces the metric's FK-less join dimensions (which the
  consumer-framed dimension list silently drops), each carrying the exact
  portable join clause to paste into `joins:` and an alias-qualified field
  reference. Fail closed: an entry is emitted only when its join clause exports
  and targets a real table; join-key columns are excluded.
- the :no-fk-path repair error is now actionable, pointing the agent at the
  metric's /dimensions resource for the join to add.

Also fix a pre-existing option-key typo in resources.clj
(:with-queryable-dimensions / :with-field-values -> the ?-suffixed forms the
readers expect) so the plain metric resource correctly skips dimensions and
field-value fetches.
2026-08-11 23:14:06 +02:00
Chris Vandoodewaard
9b9b40a5f9 centralize linting workflows (#79673)
* init file

* fix linting

* delete files moved into lint.yml

* try parallel steps

* more parallel

* rework so that dependencies are ordered properly

* match naming

* remove whitespace

* update file-paths, add concurrency block
2026-08-11 11:15:25 -04:00
Denis Berezin
24e36a9f0e UXW-4712 Fix non-applicable empty series tooltip values for funnel bar charts (#79602) 2026-08-11 17:33:55 +03:00
github-automation-metabase
c2d55422d1 Auto-update documentation for master (#79680)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-11 10:21:23 -04:00
Timur Mashara
c0fe1712f6 docs - host the model button image in the repo (#79707)
* docs - host the model button image in the repo

The troubleshooting guide linked the model button screenshot from
https://www.metabase.com/learn/images/models/model-icon.png, which now
404s with no redirect. Learn images were moved out of the marketing repo
into a CDN bucket, and that key space has been renamed several times with
old keys hard-deleted rather than redirected.

This broke the nightly Update Docs job in metabase/docs.metabase.github.io
on Aug 8-10. It only goes green today because the link gate tolerates one
broken link and the other one got fixed upstream.

Commit the image here instead of chasing the CDN. It matches how every
other image in docs/ is referenced, it gets versioned along with the
release branch so older doc snapshots keep the image they shipped with,
and it is the only form our link checkers actually validate: lychee runs
--offline (external URLs skipped) and the docs site's htmlproofer runs
--disable-external.

The source file was a WebP served with a .png extension, so it has been
transcoded to a real PNG - pngcrush in optimize-images.yml would have
failed on the original bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Optimized 1 images, saved 1.8 KB (1.7% smaller)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-11 16:16:18 +02:00
Egor Grushin
24b402cf95 Fix icon and text vertical alignment in incremental transform alert (#79681)
fix(ui): align Alert icon with text when there is no title

Icon box was locked to the 24px title row, so title-less alerts (e.g.
incremental transform warning) hung the icon above a single line of text.
Without a title, icon and close button now center on the first message line.
2026-08-11 15:04:31 +01:00
Timofey Kachalov
ca89dc06f1 Dev Data app fixes (#79604) 2026-08-11 17:50:44 +04:00
Chris Vandoodewaard
698956f32f milestones should only be updated/expected in metabase/metabase (#79539)
* onyl run set-milestone in metabase/metabase

* don't trigger cloud issues if this isn't metabase/metabase

* run ci?
2026-08-11 09:05:08 -04:00
Nemanja Glumac
4b10b24deb Run the block-master-source-branch-prs workflow conditionally (#79706) 2026-08-11 11:37:00 +00:00
Paolo Rascunà
0635ba585d Add prascuna to Metabot team and Slack map (#79704)
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
2026-08-11 12:30:14 +02:00
Andrei Simionescu
e9d98d62c3 BOT-1953: Suppress the Metabot sidebar on the conversation route (#79574)
* BOT-1953: Suppress the Metabot sidebar on the conversation route

The full-page Metabot surface disables the app-bar button and the Cmd+E
shortcut so the chat cannot open on top of itself. That check compared the
pathname against /question/ask exactly, but #77549 added a second route
rendering the same MetabotAsk, /metabot/conversation/:convoId, which
MetabotAsk itself redirects to after the first message. The sidebar came
back from there on.

useIsFullPageMetabot matches either route and drives both suppression
sites. useIsAskPage stays strict for MetabotAsk's redirect, which must not
re-fire once it is already on the conversation route. The route path now
has one spelling, Urls.CONVERSATION_BASE_PATH, so a rename cannot silently
turn the suppression off again.

The conversation matcher pins the route shape rather than a prefix, with an
optional trailing slash because react-router matches the route either way.

Cmd+E had no coverage because a note in the test harness claimed tinykeys
shortcuts do not fire under jsdom. They do, bound to Control rather than
Meta since jsdom reports an empty navigator.platform.

* BOT-1953: Match the Metabot route predicates with react-router

useIsAskPage compared the pathname to /question/ask by string equality,
so /question/ask/, which react-router serves as the same page, left the
app-bar button and Cmd/Ctrl+E active on a full-page Metabot surface.

Both predicates now go through matchPath, the matcher the router itself
uses: trailing slash and case tolerated, extra segments and an empty
conversation id rejected.

* BOT-1953: Keep the urls module to plain URL builders

The two route predicates move into the metabot hooks that use them, which
now call matchPath directly; urls/ goes back to exporting only functions
that build strings. Their url-level specs go with them, since the metabot
ui and app-bar button specs already pin the behavior.
2026-08-11 09:18:45 +01:00
Fraser
a6b5e09200 Count dynamic imports as module boundary edges (#79693) 2026-08-11 17:42:21 +12:00
Romeo Van Snick
fa3af4772e Give the reference section its own chunk (#79641) 2026-08-11 15:20:08 +10:00
Romeo Van Snick
8350e6cc4a Fetch the AI auditing pages when their route is matched (#79565) 2026-08-11 11:54:21 +10:00
Denis Berezin
2f3d32a2ba UXW-4983 disable download to pdf for empty dashboard tab (#79490)
* UXW-4983 disable download to pdf for empty dashboard tab

* Add tooltip

* Update Loki Snapshots

* Review fixes - hide button when dashboard is empty

* Update Loki Snapshots

* Fix e2e

* Review fix

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-11 00:45:44 +03:00
Kevin Kelleher
c9da277bd3 Fix dashboard click behavior for date columns without a unit (#79664)
add fallback unit for formatting unbinned date columns
2026-08-10 17:43:06 -04:00
github-automation-metabase
d225a5f8d0 Auto-update documentation for master (#79622)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-10 16:09:01 -04:00
Jeff Bruemmer
16d281da2a docs - custom viz embedding web components (#79403) 2026-08-10 19:57:07 +00:00
Chris Vandoodewaard
7592d39e42 combine all notification workflows into issue-notifactions.yml (#79659)
* combine all notification workflows into issue-notifactions.yml

* try proposed naming schema

* add newline

* remove unused on-new-feature-label job
2026-08-10 15:42:21 +00:00
Ryan Laurie
9e6e08bffb remove dead workflows and support code (#79624)
* remove dead workflows and support code

# Conflicts:
#	.github/workflows/build-for-release.yml
#	.github/workflows/main.yml
#	.github/workflows/presto-kerberos-integration-test.yml

* remove dead clojure files
2026-08-10 09:32:38 -06:00
Andrei Chernykh
9bdf46f3d7 Stop autopinning models and metrics on creation (#79548)
* Stop autopinning models and metrics on creation

* Fix e2e assertions that expected new models to be pinned

* Remove negative-case pin tests per review
2026-08-10 08:26:16 -07:00
Chris Vandoodewaard
814b57b74a only publish Github release for major and gold releases (#79478)
* only publish Github release for major and gold releases

* pr review fixes
2026-08-10 10:16:58 -04:00
Denis Berezin
570a0e18c8 UXW-4929 Resolve unknown string dashboard ids (#79586) 2026-08-10 16:43:48 +03:00
Romeo Van Snick
bfa63e2a92 Replace the Bun globals in the yarn.lock alias script to fix the type check (#79650)
* Use node:fs in the yarn.lock alias script to fix the type check

* Wrap the alias script in a main function instead of top-level await
2026-08-10 14:14:54 +02:00
Chris Vandoodewaard
ec9e8c40f0 post warning about deprecated labels (#79618)
* post warning about deprecated labels

* cleanup double/triple backport logic, fail if labels set

* fix message, whitespace

* silent api response
2026-08-10 08:00:34 -04:00
Romeo Van Snick
518ca39d56 CI: rebuild node_modules from the bun store on install (#79644)
Rebuild node_modules from bun store to avoid stale CI cache
2026-08-10 13:47:08 +02:00
Kevin Kelleher
0fc5582ae2 Prefetch queries from Exploration toolbar navigation (#79615)
* move prefetch up into ExplorationPage

* fix e2e
2026-08-10 07:37:22 -04:00
Romeo Van Snick
b750f8d8fa Split documents into its own chunk (#79349)
* Add lazyModalRoute for modals under a code-split page

* Split documents into its own chunk

* Prefetch the documents chunk from links into it

* Prefetch the comments sidesheet with the document page
2026-08-10 21:02:14 +10:00
Romeo Van Snick
fc7d75b01f Fix Snyk scan by renaming bun's aliased yarn.lock keys (#79646)
* Fix Snyk scan by renaming bun's aliased yarn.lock keys

* Trigger the Snyk workflow on changes to the alias fix script
2026-08-10 21:02:02 +10:00
Uladzimir Havenchyk
af266d1426 fix: pass isSensible to custom viz (#79325)
* fix: pass isSensible to custom viz

* fixup

* address review

* fixup

* address review

* address review

* fixup

* fixup

* address custom viz re-render

* fixup
2026-08-10 12:12:01 +02:00
Andrei Simionescu
660b7ac2db BOT-1944: Fix two Metabot tool failures (#79498)
* Fix two Metabot tool failures surfaced by the reliability data

get_transform_details passed the transform's query map straight into the XML
formatter. clojure.data.xml reads a map in second position as an attribute map
and then throws on MBQL 5's namespaced :lib/type key, so every query-source
transform failed and only Python ones (no :query key) got through. That is 72
errors in 75 calls over 90 days, across 15+ instances. The query now renders as
SQL text, matching how the line below already handles :target.

create_sql_query has two implementations sharing one tool name, and neither used
the shared agent-error contract. The :sql profile's code-edit variant had no
error handling at all, so the :agent-error? exceptions validate-database-access
raises escaped uncaught and were tracked as tool failures. That variant errored
on 26% of calls against 0.04% for the :internal one. Both now delegate to
handle-agent-error, as the other tools do: the intended validation message goes
back to the model, and anything unexpected is rethrown so real defects stay
visible in the telemetry rather than being reported as successes.

Neither tool had a test; both now do.

* Address review: transform rendering and 403 classification

Transform source queries now render verbatim (data.xml escaped <, >, &,
which breaks old_string matching against the real query), python bodies
render, and the source type attribute is no longer a keyword literal.
Notebook-built queries fall back to EDN with the attached metadata
provider stripped, shared with transform->xml, whose string guard always
dropped the normalized (map) query.

validate-database-access tags the plain 403 from read-check so both
create_sql_query variants classify permission failures as agent errors,
and the pre-classification ERROR logs are gone; run-tool already logs
both paths at the right levels.

* Tolerate string queries in transform-query->text

Legacy transforms stored raw SQL strings under :query, and orphaned sources
skip normalization, so the query reaching this helper is not always a map.
dissoc on a string threw ClassCastException; the search formatter's old
string guard had tolerated exactly this shape. Strings now pass through
verbatim, and the helper's namespace gets its first tests.

* Use a doc link for escape-xml in the escape-xml-content docstring

* Read orphaned sources' string keys in extract-sql-content

Orphaned transform sources skip normalization and keep their JSON string
keys, so the keyword probes missed them and the model got EDN of a
string-keyed map instead of the SQL. Multi-stage queries now fall through
to the EDN branch too: stage 0 alone is partial SQL and hid the rest.

Also tags the read-check 404 alongside the 403, closing the race where
the database vanishes between the exists? check and the read.
2026-08-10 10:26:23 +01:00
Fraser
cc136d9725 Move the query pickers into querying (#79561) 2026-08-10 20:50:57 +12:00
Romeo Van Snick
aee69e552a Drop prettier/standalone from the expression formatter and load the printer on demand (#79639)
* Print expressions with prettier's doc printer instead of prettier/standalone

* Load the expression printer on demand
2026-08-10 13:13:28 +10:00
metabase-bot[bot]
4dc84c1615 [chore] Update E2E test timings with recent data (#79628)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-08-09 09:13:08 +02:00
Nemanja Glumac
fb00ed06ef Remove the test-results S3 upload (#79552)
We no longer upload JUnit XML to S3, so the action and all 47 call sites
go. Test results stay visible through the JUnit reporter and CI
Conductor.

`id-token: write` drops from minimal-tests and transforms-python-stress-test,
whose only OIDC consumer was the upload's AWS credential handshake. It
stays in run-tests, which must keep granting it to drivers.yml's ECR login.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:41:53 +02:00
Anderson
7675914513 Add in-collection type filters with dynamic options (#79340)
* Add dynamic collection type filters

* Complete collection filter coverage

* Harden dynamic collection filters

* Simplify collection type filters

* Address review feedback on collection type filters

* Additional clean up
2026-08-07 18:57:13 -03:00
Anderson
28160265ec Implement in-collection text search (#79333)
* Add in-collection item search

* Add collection search end-to-end coverage

* Fix collection search navigation and coverage

* Add loading state and fix spacing for collection search

* Keep collection tables mounted during refetches

* A couple UI refinements

* Show collection search loading inline
2026-08-07 18:57:12 -03:00
Anderson
3ec36759e7 Backend: search and type-filter metadata for collection items (#79332)
* Add collection item search and filter metadata

* Add collection authority filters

* Cover collection search edge cases

* Keep snippet pinned-state behavior unchanged

* Never report snippets in available_models

* Remove collection authority filters

* Code cleanup

* Cover explorations in available models

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-07 18:57:12 -03:00
Denis Berezin
d8a19a330a UXW-4939 Fix crash on dashboard tab duplication (#79554)
Co-authored-by: azharizz <azharizzannada@gmail.com>
2026-08-08 00:34:11 +03:00
Chris Vandoodewaard
a9ca5965c4 Fix all actionlint findings in .github/workflows (#79606)
Brings `actionlint` (v1.7.12, the version CI pins) to zero findings, down
from 186.

Real problems, not just lint noise:

- `pr-env-destroy-manual.yml` used an unquoted YAML scalar containing
  ` #`, so YAML truncated the command at the comment marker and the step
  actually ran `echo "Destroying PR environment for PR` — an unterminated
  quote. Moved to a block scalar with the PR number in an env var.
- `yaml.yml`'s `yaml-linter` job read `needs.files-changed.outputs.yaml`
  without declaring `needs:`, so its `if` was always false and the linter
  never ran.
- `containerize-uberjar.yml` referenced `github.event.inputs.commit` in
  three places, but its `workflow_dispatch` declared no inputs.
- The three Slack notification workflows interpolated
  `github.event.issue.title` straight into a `github-script` body; it now
  goes through an env var.
- Several step-summary blocks wrote `echo '... ${{ ... }}'` in single
  quotes, which never expanded as intended.

The rest is mechanical: quoting `$GITHUB_OUTPUT`/`$GITHUB_ENV`/
`$GITHUB_STEP_SUMMARY` redirect targets, grouping consecutive appends
into `{ ...; } >> file`, replacing `ls | xargs basename` with a glob
loop, `A && B || C` with `if/then`, `[[ "$?" != 0 ]]` with `if ! cmd`,
and assorted quoting.

`containerize-jar.yml`'s `release` input is now a real `boolean` rather
than a string of `"true"`/`"false"`, which also removes the
`${{ 'true' }}` workaround at its call sites.

Adds `.github/actionlint.yaml` to declare the `arm-ubuntu-24-2core`
self-hosted runner label.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:53:02 -04:00
Nathan Voxland
7b58c182a3 Better management of database metadata (#79181) 2026-08-07 13:40:45 -05:00
John Swanson
a2bc99107e UXW-4967: two tier research catalog (#79109)
* Split the research catalog tool to keep its payload bounded

get_research_candidates returned every research-eligible metric with every
candidate dimension inlined — on instances with many metrics the JSON blob
overwhelmed the following LLM request's context.

Two-tier replacement:

- list_research_metrics (new): slim index — id, name, description
  (truncated to 150 chars), in_library — no dimensions. Optional q filters
  by substring on metric and dimension names.
- get_research_candidates now requires metric_ids (max 20 per call) and/or
  q, and returns a deduped shape: same-group dimensions resolve to the same
  underlying Field, so descriptive fields (name, types, interestingness)
  are stated once per dimension group, with each metric's dimension id and
  per-metric display name (renames are common) in
  dimension_id_and_name_by_metric. A q match beyond the cap is ranked
  library-first-then-interestingness, truncated, and stamped
  {truncated, shown, matched}. Argument-less calls return an instructive
  error instead of the catalog.

add_research_groups is unchanged — the ids the model echoes back are the
same per-metric dimension ids as before. explorations.selmer updated to
describe the new contract (factual sync only).

* Cap the list_research_metrics index

Load-testing with 3,000 metrics (~62k dimensions) showed the unfiltered
index was still a ~394 KB / ~100k-token blob — slim rows don't save you
when there are thousands of them. Above 500 matches the index is now
ranked library-first-then-interestingness, truncated, and stamped
{truncated, shown, matched} — the same contract as the
get_research_candidates q cap — so the model knows to narrow with q.
Under the cap, rows keep their natural library-first-then-name order.

At the 3,000-metric load the unfiltered index drops to ~66 KB; q-filtered
responses are unaffected.

* Code review cleanup

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-07 13:39:04 -05:00
Andrei Chernykh
b346394afd Convert LegacyChoropleth to TypeScript (#79497)
* Convert LegacyChoropleth to TypeScript

* Address review: FIXME on memoization, discriminated union for getMapProjection
2026-08-07 11:29:58 -07:00
Andrei Chernykh
62351c70d3 Convert remaining spec-only unit spec files to TypeScript (#79545)
* Convert remaining spec-only unit spec files to TypeScript

* Update frontend/src/metabase-lib/v1/queries/NativeQuery.unit.spec.ts

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>

---------

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
2026-08-07 10:23:37 -07:00
Alexander Polyankin
97e8d67036 Declare two request params the handlers read but never declared (#79594)
* Declare two request params the handlers read but never declared

Request decoding drops keys a schema does not declare, so a handler that
reads an undeclared key silently gets nil.

- POST /api/card/pivot/:card-id/query destructured `parameters` and
  passed it to `process-query-for-card`, but declared only
  `ignore_cache` (and `dashboard_id`) — parameterised pivot queries
  silently ignored their parameters. Declared exactly as the non-pivot
  sibling in the same namespace does.
- POST /api/notify/db/:id destructured `synchronous?`, the documented
  flag that makes the sync run synchronously, without declaring it, so
  every call ran asynchronously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add regression tests for both undeclared params

Each test fails on master and passes with the schema fix:

- pivot: reuses the native card whose `{{category}}` template tag is
  `:required`, so the query only runs if the request body's value reaches
  the query processor. Without the declaration the request 500s.
- notify: blocks the redefined `sync-database!` on a promise and asserts
  the response is still outstanding, which can only hold when the handler
  derefs the sync future. Without the declaration `{:success true}` comes
  back immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:18:08 -04:00
Eric Normand
62f8cd51ee Avoid row-constructor IN in multi-catalog Databricks sync (GHY-4263) (#79533)
* Avoid row-constructor IN in multi-catalog Databricks sync (GHY-4263)

On a multi-catalog Databricks connection, describe-fields and describe-fks
filtered schemas with `(table_catalog, table_schema) IN ((?, ?))`. Databricks'
planner is orders of magnitude slower on that form against
`system.information_schema.columns` than on AND-ed equalities: on a Unity
Catalog workspace with ~220k columns the same query drops from 3m27s to 2.0s
when the predicate is rewritten. sync-fields issues one such query per schema,
so a ~50-schema sync took hours and never ran to completion.

Emit `catalog = ? AND schema = ?` per pair, OR-ed together for multi-pair
calls, instead of the row constructor.

* Update comment
2026-08-07 17:07:50 +00:00
metamben
f1613d8e2e Honor report-timezone in SQL Server for time ranges (#79271)
Closes QUE2-770
2026-08-07 16:05:17 +00:00
Tyler Plude
f7aa6d0f40 [BOT-1929] Moonshot provider support for Metabot (#79515)
* groundwork cleanup & gap fixing in provider adapters

* moonshot / kimi BE support

* FE support

* fix provider name & default model to kimi k3

* clean comments
2026-08-07 08:57:02 -07:00
Nemanja Glumac
11091c1db2 Adjust the quarantine consumer for top level suites (#79547)
* Honor suite-level quarantine rules in the gate

/api/quarantine serves a top-level `suites` array of glob rules alongside
`tests`. A rule matching the job's reporting suite quarantines the whole
suite, so the gate passes without comparing failures test by test.

Globs match as an unanchored, case-insensitive substring and support `*`,
`?` and `{a,b}` — a subset of the minimatch semantics ci-conductor
validates against, compiled inline because this package carries no
runtime dependencies and CI runs it straight from the checkout.

Both response keys are read as optional, so the script and the server
need no lockstep deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the driver "info" status

Suite-level quarantine rules say the same thing from CI Conductor, for
every suite rather than drivers alone: run it, but don't let it gate.

mage now reads only "skip" from ci-test-config.json, and no longer emits
a per-driver status; the workflows drop the plumbing that carried it,
along with the job summary and PR comment that reported an info driver's
real outcome. The quarantine gate's own verdict and CI Conductor's UI
cover that visibility.

"skip" stays: not running a cloud driver at all saves real spend and has
no equivalent in the quarantine endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Remove dead checks from drivers

* Tweak comment

* Drop the "info" status from notification

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:55:54 +02:00
github-automation-metabase
55e63c2b6f Auto-update documentation for master (#79557)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-07 11:16:25 -04:00
Nemanja Glumac
eee8e4e4e9 Key the uberjar artifact on the dispatched commit (#79449)
The upload named the artifact after the event's own commit, while every
lookup around it — the concurrency group, check-existing-artifacts, the
bundle-stats upload and check-jar-health — keys on the dispatch input
first. A dispatch with an explicit commit therefore built that commit and
uploaded it under the dispatch branch's tip.

Inline the composite action into its only caller so the name comes from
the same expression as the lookups instead of a second copy of the
fallback chain.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:03:25 +02:00
Alexander Polyankin
ab962a316a Schemas for API docs (#79521) 2026-08-07 10:51:01 -04:00
John Swanson
12f261bdf1 ignore missing sandbox/perms features when syncing (#78735)
* ignore missing sandbox/perms features when syncing

for consistency with other code, we should not worry about whether the
premium feature in question is actually enabled. If sandboxing is
enabled and configured and we're runnning EE code we should respect it
when a new table/group/db is synced.

* dedupe implied rows when setting data permissions

build-database-permission and build-table-permissions merge the results
of their recursive implication calls with `merge-with concat`, and
several implication paths can produce the same row:

- setting view-data to :blocked implies `transforms :no` both directly
  and via the implied `create-queries :no`, so the DB-level path
  INSERTed the transforms row twice
- coalescing table perms to a DB-level row (all tables agreeing on a
  create-queries value) implies a DB-level `view-data :unrestricted`
  row, and the recursive table-level view-data call coalesces to that
  same row, so the table-level path INSERTed the view-data row twice

Nothing deduped the merged :to-insert list, so every such call silently
persisted duplicate data_permissions rows. Duplicate DB-level rows are
the arming condition for the UXW-4927 sandbox data leak: the
going-granular path for newly-synced tables only deleted one row per
(group, perm-type), and a surviving duplicate out-votes the new table's
:blocked row at enforcement time.

* make the going-granular DELETE+INSERT atomic

The bulk path for newly-synced tables issued its DELETE and INSERT
outside any transaction, so a failed insert — e.g. the unique-constraint
violation added in the next commit — could leave the DB-level rows
deleted with the table-level expansion half-written. Wrap the pair in a
transaction. Nested transactions are savepoint-based (see
metabase.app-db.connection), so this is safe under callers that already
run inside a transaction, like the Table after-insert hook.

* dedupe data_permissions and add a unique constraint

data_permissions had no unique constraint on
(group_id, perm_type, db_id, table_id), so nothing prevented duplicate
rows for the same scope, and duplicate DB-level rows armed the UXW-4927
sandbox data leak. Three v58 changesets (v58 is the oldest release this
will be backported to):

1. Delete existing duplicates, keeping the most restrictive perm_value
   per scope, lowest id as tie-break.
2. Add a generated column unique_perms_helper = COALESCE(table_id, -1).
   All three app DBs treat NULLs as distinct in unique indexes, H2 has
   no partial indexes, and MariaDB has no functional indexes, so a
   NULL-safe generated column is the only portable mechanism — same
   approach as metabase_table.unique_table_helper (v55.2025-05-19).
   Unlike that precedent the base column carries an ON DELETE CASCADE
   foreign key, which MySQL rejects for STORED generated columns
   (errno 1215), so MySQL/MariaDB use a VIRTUAL column; Postgres has no
   virtual generated columns and no such restriction, so it keeps
   STORED. Dedupe, constraint enforcement, and FK cascade verified on
   Postgres 17, MySQL 8.0, and MariaDB 10.6.
3. Add a unique constraint on
   (group_id, perm_type, db_id, unique_perms_helper).

Inserts stay fail-loud: after the previous commits, a constraint
violation can only mean a genuine bug, and it should abort the write
(the going-granular DELETE+INSERT now runs in a transaction) rather
than be silently absorbed.

The generated column is excluded from the model (after-select) and from
load-from-h2/dump-to-h2 copies, mirroring unique_table_helper. Tests
that inserted raw duplicate rows on top of the hook-created defaults now
set permissions through the API instead.
2026-08-07 09:23:47 -05:00
Kevin Kelleher
0f14b54daa Improve Explorations sidebar empty states (#78264)
* sidebar empty states

* fix e2e test

* fix tests after rebase on master

* keep empty failed/canceled threads in the sidebar so they can be restarted

* review comment
2026-08-07 09:45:45 -04:00
Timofey Kachalov
95c0c90c6a Support source: card for query hooks and passing aggregations/breakouts/filters (#79456) 2026-08-07 17:34:49 +04:00
Kevin Kelleher
637e1b4344 Explore further from brush filter (#78144)
* explore further from brush filter

* review comment

* fix tests, self review

* Code cleanup

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-07 08:58:13 -04:00
Kevin Kelleher
692050c2db Explorations prompt updates (#78228)
* prompt updates

* fix flaky test
2026-08-07 08:15:49 -04:00
Romeo Van Snick
258b1708da Split the query builder into its own chunk (#79342)
* Prefetch a code-split page when a link to it is hovered

* Show a fallback while the router hydrates a lazy route

* Split the query builder into its own chunk

* Move the query builder's action types off the actions barrel

* Split the question selectors into their own module

* Read the question selectors from the leaf module in the app shell

* Import the query builder's view components directly in table editing

* Keep a pending lazy navigation from being replaced

* Prefetch the query builder chunk from links into it
2026-08-07 18:36:21 +10:00
Timur Mashara
661e899223 how to use metabase (#79024)
* how to use metabase - initial update

* update dark mode styles

* update checklist items
2026-08-07 08:28:57 +00:00
Timofey Kachalov
b918b9e5f4 Improved Data App dev plugin to handle buildId to prevent diagnostic endpoint poisoning of data frop previous HMR builds (#79447) 2026-08-07 04:22:45 +00:00
metamben
aba318ec2e Preserve overrides of model based on sandboxed table (#79334)
* Preserve overrides of model based on sandboxed table

Closes QUE2-778
2026-08-07 01:12:05 +03:00
metamben
7171418bdb Use min_duration_ms in cache-strategy (#79326)
This matches configured properties like `refresh_automatically` but doesn't match the ones calculated in the BE, like
`avg-execution-ms`.
2026-08-07 01:11:47 +03:00
metamben
628a3220ff Fix parameter selection cutoff when refreshing the cache (#79272)
Closes QUE2-762
2026-08-07 01:11:29 +03:00
metamben
fe731bdb2e Preserve cell information in pivots (#79266)
* Preserve cell information in pivots

Closes QUE2-777
2026-08-07 01:11:15 +03:00
metamben
87fabef5e3 Prevent an analytics configuration error to shadow the timeout (#77098)
Closes QUE2-734
2026-08-07 01:10:58 +03:00
metamben
911c0ef816 Look up long display-name from previous stages (#77230)
* Look up long display-name from previous stages

Closes QUE2-729

* Narrow scope
2026-08-07 01:10:41 +03:00
Nemanja Glumac
0ec5c4ea19 Simplify failed backend artifact name (#79441)
* Drop the redundant job id from the granular-rerun artifact name

For most drivers `github.job` and `junit-name` are the same string, so the name
came out as `failed-tests-be-tests-snowflake-ee-be-tests-snowflake-ee-Driver
Tests-attempt-1`.

`junit-name` plus `artifact-suffix` already discriminate every call site in the
artifact namespace that run-tests.yml shares between app-db.yml, backend.yml,
drivers.yml and semantic-search.yml — including the `be-tests-postgres` job that
exists in both app-db.yml and drivers.yml, where the two derive the same
`be-tests-postgres-ee` junit-name but differ on suffix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop the rerun artifact restore from erroring when there is nothing to restore

An attempt that fails with nothing attributable to a var uploads no failed-tests
artifact, so the next attempt's restore legitimately finds none and falls back to
the full suite. That is the designed path, but `download-artifact`'s `name` input
treats a miss as an error, printing a red annotation on a job whose real failure
is elsewhere.

`pattern` filters the artifact list the action already fetches and downloads
nothing on zero matches, so this is the same single API call as before and costs
no extra rate-limit budget. The pattern is an exact literal, and artifact names
are unique within a run, so it matches at most one.

`merge-multiple` is deliberately left unset: a single match already lands at
`target/failed-tests`, and setting it would merge a hypothetical second match into
that one file — letting a matrix leg rerun another leg's failures — where the
default nests them and falls back to the full suite instead.


Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Vandoodewaard <chris.vandoodewaard@metabase.com>
2026-08-06 21:30:33 +02:00
Kevin Kelleher
0e5501550b Show explore further toast only when a page has queries (#79530)
don't show explore further toast until queries are ready, matching sidebar
2026-08-06 13:42:38 -04:00
Andrei Chernykh
ecbacf9720 chore: Convert metabase-lib/v1 field-value utils to TypeScript (#79075)
* Convert metabase-lib/v1 field-value utils to TypeScript

* Address review: normalize getRemappings to [value, label] pairs, adopt honest RowValue-keyed mapping types with no casts, add remapping utils specs
2026-08-06 10:22:58 -07:00
Andrei Chernykh
4d6c2d2dbe Render pinned collection items in a single grid of compact static cards (#79493)
* Render pinned collection items in a single grid of compact static cards

* Fix pinned-card e2e waits that relied on removed previews

* Register explorations as pinnable and movable drag types

* Address review comments
2026-08-06 10:11:12 -07:00
github-automation-metabase
978332fcf7 Auto-update documentation for master (#79495)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-06 12:46:07 -04:00
Chris Truter
109f3ec4fb Remove module dependency cycles around utils (#75719) 2026-08-06 17:17:51 +02:00
Chris Truter
17ff890b7d Skip reanalyzing search models on every row hook (#78286)
* Memoize search model hooks

Cache model hooks by the registered spec methods using a single-entry FIFO. This avoids repeated per-row hook construction while bounding reload-time cache growth and keeping invalidation best-effort.

* Trim the model-hooks cache test

The finally-clause recompute was dead: restoring the method restores the cache key, so
the probe's entry can never be hit again. Split the caching assertion out of the
invalidation test.

* Assert the model-hooks cache is warm before probing invalidation

The first call registers spec methods as it resolves models, so it caches under a key
that is stale by the time it returns. Probing from that state proves nothing: the miss
comes from the stale key, not from the redefinition.

* Note that the model-hooks cache key is only an invalidation token

* Set *warn-on-reflection* in the search spec test namespace

Clj-Kondo flags any interop call in a namespace that leaves the var unset; the
.addMethod probes are the first interop here.
2026-08-06 17:16:59 +02:00
Maksym Yakubych
94b673140b [Metabot] Pretty print tool call results (#79477)
* [Metabot] Pretty print tool call results

Split a tool call's result into Response, Structured output and Other
fields sections instead of dumping the raw JSON-encoded result map into
a single JSON code editor. Text output renders with real newlines, and
structured_output (also the kebab-case spelling kept by rows migrated
from the v1 format) gets its own section.

* Simplify the tool call result parser
2026-08-06 14:46:05 +00:00
Fraser
ceccf16d4d Delete thin wrapper selectors over settings reads (#79144) 2026-08-06 11:14:10 +00:00
Egor Grushin
cb55deead4 Gdgt 2975 hide or handle create index in read only mode (#79452)
* Centralize remote-sync read-only handling in useTransformPermissions

Fixes Create index (and other transform mutations) being offered in
remote-sync read-only mode and failing with 403. The hook now returns
combined readOnly plus granular permissionsReadOnly/remoteSyncReadOnly,
and all direct getIsRemoteSyncReadOnly usages across transforms are
replaced with hook values or props. Run button stays gated by
permissions only (running is allowed in remote-sync read-only).

* Rename RunButtonSection readOnly prop to permissionsReadOnly

* Drop redundant remoteSyncReadOnly from incremental switch disabled state

* Fix plugin setup order in indexes page spec
2026-08-06 09:29:20 +00:00
Egor Grushin
eadf23cd60 Add tooltip explaining Managed/Unmanaged index source (#79455)
* Add tooltip explaining Managed/Unmanaged index source

* addressing the PR's comments

* Lint against unchained ttag c() calls

* Revert "Lint against unchained ttag c() calls"

This reverts commit 9c25250638.

* Remove unused Icon import

* Fix formatting in columns.tsx
2026-08-06 10:25:31 +01:00
Egor Grushin
41b44d34d0 Remove duplicated Indexes heading on transform indexes page (#79448) 2026-08-06 10:24:42 +01:00
appleby
92b6b2120c Enable Kimi K3 and Deepseek V4 Flash 0731 via OpenRouter (#79207)
* Enable Kimi K3 via OpenRouter

* Enable DeepSeek V4 Flash via OpenRouter

* Raise the conversation-title output budget from 128 to 512 tokens

Reasoning models spend the output budget on reasoning before emitting the forced
tool call, so at 128 tokens the structured call often returns no tool call at all
and no title is saved.
2026-08-05 18:11:50 -07:00
Egor Grushin
4efc94ee84 Lint against unchained ttag c() calls (#79502) 2026-08-06 00:09:49 +01:00
Denis Berezin
db0985e3ee Fix sankey click behavior value passed to dashboard filter (#79380) 2026-08-06 01:02:29 +03:00
Kevin Kelleher
789d334847 Explorations (#73244)
* Fixed failing CI

* Add query timestamp, refactor error marker

* no-mbql-introspection pass

* Order temporal-axis exploration variants by date, not aggregation

When the row cap fires on a time series, aggregation-desc ordering keeps
the highest-count buckets scattered across the full range: gaps in the
x-axis, old spikes beating recent data. Temporal axes now order by the
temporal breakout desc (aggregation desc as tiebreak) before the limit,
so the most recent contiguous window survives.

- time-facet: order by the temporal breakout (second breakout) desc
- default: branch on dim type — temporal dims by recency, categorical
  dims keep aggregation desc
- per-value-time-series: gains the same date-desc cap (had none, so a
  single fine-grained series could blow query-caching-max-kb)
- all three also filter the NULL date bucket: it can't render on a time
  axis, and its position under ORDER BY DESC is warehouse-dependent
  (Postgres NULLs first, MySQL last), making cap survival
  non-deterministic otherwise
- temporal-pattern-day/hour unchanged (bounded, no cap interaction)

* Added debug info to interestingness calculations

* even less mbql introspection

* oops, delete .scratch

* Sidebar UI fixes - error marker, loading shimmer

* Interestingness >= 0.7

* Adjust prompts list animation

* Fix e2e

* Note that the LLM query planner is a POC, not used in production

* Fix searching for explorations

* Use llm-metabot-provider for everything

* Log interestingless calculation as info for now

* Log interestingless calculation back as debug

* Fix FE lint

* fix ai summary showing dirty while generating

* Use llm-metabot-provider

* collection breadcrumbs

* add back null values for temporal variants

* order by for temporal pattern variants

* fix top-n-other

* doh, fix temporal pattern queries

* fix name in viz header, don't show be error in tooltip

* sort numeric bins in heat map header, fix tests

* UXW-4483 Add matching colors for category + breakout charts (#75912)

* Fix CI

* Fix units, fix loki

* UXW-4522 Update last query run timestamp in background

* Fix loki

* Fix e2e

* Improve Explorations viz types (#75958)

* UXW-4483 Add matching colors for category + breakout charts

* improve viz types first pass

* add error boundary

* add row chart support for dimension_value_colors

* Fix typecheck

* fix tests

* better handling of segments with row fallback, fix null color bug

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>

* UXW-4402: Make explorations respect sandboxing/impersonation/db routing (#75572)

* Make explorations respect sandboxing/impersonation/db routing

* some self-review fixes

* more cleanup

* test fixes

* doh

* Fixing CI failures

* capitalize 'By' consistently

* Fixed CI failure

* add loki snapshots

* Review fix

* Fixes after merge

* Rework Explorations UX (#76300)

* comment on exploration viz first pass

* wip

* wip

* wip

* UX changes first pass finished

* code cleanup, fix, add tests

* address review comments

* Code cleanup

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>

* Refine explorations with metabot (#75326)

* teach metabot about new exploration ux

* refine explorations

* address self-cr

* pass over FE

* CR response

* fix test

* Add :model/Exploration to api + eid-translation model-imports

These modules reference :model/Exploration after merging the latest
explorations base; declare the imports to satisfy model boundaries.

---------

Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>

* Harden explorations runner:
- planner crash recovery
- Bound timeline scoring
- add metrics

* UX tweaks

* Additional logging of errors on ai summary, plus increased thinking budget

* Improved timeline interestingness calculation

* Handle sonnet 5 etc better

* Make explorations groups a real thing (#76643)

* Rename ExplorationThreadGroup model to ExplorationBlock

Explorations organize their queries in a hierarchy: an Exploration has
Threads (one per run), a Thread has Blocks, and a Block is one
metrics-by-dimensions grouping that renders as a single sidebar heading.
That persisted grouping entity was named ExplorationThreadGroup, and the
word "group" was overloaded across the backend and frontend. Rename the
entity to Block — the name the frontend already uses — so the larger
data-model changes that follow start from one clear name. No behavior
change.

Renames :model/ExplorationThreadGroup -> :model/ExplorationBlock and
table exploration_thread_group -> exploration_block.

- Append a renameTable changeset rather than editing the createTable in
  place: the original is already applied to running dev DBs, so an
  in-place edit would fail checksum validation and force a DB rebuild.
- Rename the model namespace/file; update all call sites, the require
  alias (:as block), and insert-blocks!.
- Update model registrations (resolution, serdes models, copy, entity-id
  test) and re-sort the entries.
- Refresh the model's docstrings to describe a "block".

The planner's :thread-groups ctx key and the read-side group-tree are
left untouched here; they belong to later read-side work.

* Persist exploration pages with stable ids reconciled across reruns

Within a Block, an Exploration's queries are organized into Pages: a Page
is the bundle of queries for one (metric, dimension, query_type). Until
now a Page existed only as a derived, content-addressed key computed at
read time, so anything that wanted to point at a Page had to reference a
string that changed whenever the grouping definition changed. This makes
a Page a real persisted row whose surrogate id is its identity, so
comments (and stars, coming next) can anchor to a Page and survive both a
rerun — where queries are deleted and regenerated — and a future
re-grouping.

Introduce ExplorationPage: the persisted (block, card, dimension,
query_type) page within a block.

- New exploration_page table + model, FK to exploration_block (cascade)
  and report_card. Registered in resolution, serdes (excluded), copy,
  and the entity-id test.
- Add exploration_query.page_id (NOT NULL, FK, cascade): the reconciler
  stamps it on every query, so it's required from the start. group_id
  stays for now as the block pointer the read-side and deep-link URLs
  use; dropping it waits for the read-side rework.
- Reconcile in insert-plan-rows!, the single insert point for both
  initial Start and rerun: find-or-create a page per distinct key
  (stamping page_id), then GC pages left with no queries. reset-thread-
  for-rerun! is unchanged — pages survive a query delete (cascade is
  page->query), so find-or-create recovers the same page ids on rerun.
- No DB unique index on the key: dimension_id is text (not portably
  indexable on MySQL) and planning is single-threaded, so uniqueness is
  enforced by find-or-create and matching is done in Clojure.
- Migration changesets appended (the table/group_id changesets are
  already applied to the running dev db).

Tests cover page-id stamping, rerun id-stability, orphan GC, and perms.

* Return nested blocks→pages from the read API; drop query.group_id

An Exploration Thread's structure is Blocks, each containing its Pages,
each Page bundling the ids of its queries. Until now the backend instead
returned a hand-rolled flat list of typed nodes (sidebar/singleton/page)
linked by parent_group_id and keyed by auto:<...> strings, which the
frontend then re-nested. Now that Blocks and Pages are real rows, return
the nested shape the tables already encode: GET /:id returns :blocks,
each with its :pages, built from ExplorationBlock + ExplorationPage rows
and queries grouped by page_id.

- New metabase.explorations.blocks (renamed from .groups): blocks-tree
  builds the nested shape; block headings + page names + score-sorting
  ported over; block-display-name made public for the LLM planner ctx.
- Deep links go to a page by id: page-url → /question/research/<exp>/
  page/<page-id>; the AI-summary and document-append builders pass the
  primary query's page_id.
- Drop exploration_query.group_id (and its FK/index); a query reaches its
  block through its page now — build-row-context (used by the runner to
  finalize each row's MBQL) resolves the block via the page, which is what
  the group_id read there used to do.
- API schemas: ExplorationQueryGroup → ExplorationBlockNode +
  ExplorationPageNode.
- Migration changesets appended (the table is live on the dev db).
- Rewrite the read-model + endpoint tests for the nested shape; every
  ExplorationQuery test fixture now hangs off a page (page_id NOT NULL).

The create payload key stays :groups for now (separate input contract,
touches the data-picker flow). FE consumption of :blocks lands next.

* Consume nested blocks→pages on the frontend

The frontend renders an Exploration Thread as sidebar headings — one per
Block — with a navigable item per Page (a Page being the bundle of
queries for one metric/dimension/variant). It used to receive a flat
typed-node `groups` list and re-nest it itself; this switches it to the
nested `blocks` array (block → pages) the read API now returns, and
renames the navigable unit from "group" to "page" throughout. Users see
the same sidebar and charts — this lines the frontend up with the
backend's shape.

- exploration.ts: add ExplorationBlockNode / ExplorationPageNode; thread
  `.groups` → `.blocks`; remove ExplorationQueryGroup / its display-type;
  the navigable id is now a page node id (kept as an opaque string).
- Sidebar utils build the tree directly from blocks→pages (one heading
  per block, one child per page); score-sorting + empty-block filtering
  preserved.
- The navigable unit is renamed group→page end to end: the route segment
  becomes /page/<id>, matching the backend's AI-summary deep links. The
  selection/url/comment-draft id stays a string via String(page.id), so
  the existing string-keyed plumbing is unchanged.
- Visualization toolbar/header/comment-draft props renamed group→page;
  the mark-as-interesting handler is left a stub for the stars feature.
- Specs, stories, and test-utils rewritten to the nested shape.

The create-payload request key stays `groups` (separate input contract).

* Retain comment-anchored pages across rerun GC; point comment URLs at /page/

A Page bundles the queries for one (metric, dimension, query_type), and a
comment can be anchored to a Page — comment.child_target_id holds the page
id. On a rerun an Exploration's queries are deleted and regenerated, and
any Page left with no queries is garbage-collected. A Page a comment
points at must survive that cleanup even when it ends up empty, or the
comment would resolve to nothing. This makes the rerun GC comment-aware
and moves comment deep-links onto the page route.

- gc-orphan-pages! now retains an orphan page when some "exploration"-
  targeted comment's child_target_id equals the page id (compared as
  strings). Empty + no comment is still GC'd as before. Page ids are
  globally-unique PKs, so matching on child_target_id alone is
  unambiguous.
- exploration-comment-url: /group/<child> → /page/<child>, matching the
  page route and blocks/page-url; stale "group ID" docstring fixed.
- explorations imports :model/Comment for the retention lookup (comments
  doesn't depend on explorations, so no dependency cycle).
- No backfill: the dev Postgres has zero exploration comments and zero
  auto:* child_target_id rows, so the optional migration is skipped.
- Test: comment on a page → drop that page's selection → rerun → the page
  is retained and the comment still resolves; a no-comment empty page in
  the same rerun is still GC'd.

* Generate short and long page names from metric, dimension, and variant

A Page — the bundle of queries for one (metric, dimension, query_type) —
is shown in the sidebar under its Block's heading, but is also referenced
on its own elsewhere (comment deep-links, AI-summary chart titles,
search). Page names are derived, not stored, so a Page needs both a short
label that omits whatever its Block heading already shows (e.g. under a
"Number of Orders" heading: "By Category over time") and a full,
self-describing label for context-free places ("Number of Orders by
Category over time"). This generates both from the Page's parts — the
metric name, the dimension name, and a short per-variant qualifier —
rather than by string-slicing a finished name.

- variants/variant-qualifier: query_type → a short, param-free localized
  qualifier ("over time", "(Day of week)", ...) or nil for the default
  breakdown. Lives next to query-name so all variant naming is in one
  place. Unit-tested.
- blocks: page-long-name and page-short-name generate from parts (metric
  + dimension + qualifier) using tru templates and the block's anchor;
  page-node emits :name and :long_name.
- API ::ExplorationPageNode and the FE ExplorationPageNode type gain
  :long_name (FE display wiring left for separate work).
- Same-dimension pages that differ only by variant (default vs
  top-n-other) are now distinctly named instead of colliding; a
  blocks-tree test that asserted the collision now asserts they differ.

* Add ability to star pages, sidebar tabs UX (#76755)

* add ability to star, sidebar tabs

* - renamed "user interestingness" to "starred"
- Let starred just be true|false, no null
- Removed DELETE starred API, just PUT now

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>

* Fix CI: test compile error, migration boolean type, e2e formatting

- documents/search_test.clj: qualify text->prose-mirror-ast with the
  documents.test-util/ alias in the exploration-thread exclusion test.
  The unresolved symbol broke backend test compilation, which cascaded
  to every app-db/driver test job and clj-kondo ("0 tests" everywhere).
- 20260428_explorations.yaml: use ${boolean.type} instead of bare
  boolean for exploration_page.starred (required post-v49 so MySQL
  uses BIT(1)).
- oxfmt formatting for two explorations frontend files (fe-lint).

* Update e2e specs for reworked comment URL + explorations sidebar

- comment helper: match the node comment link by path only. The
  `?new=true` marker was removed from comment URLs (base commit
  "respect query params in all urls"); the CommentsMenu unit test was
  updated but this e2e helper was missed. Verified the comment flow
  still works (opening a node's thread shows the new-comment editor).
- explorations spec: align with the reworked sidebar —
  * `by <dim>` leaf label is now `By <dim>`
  * Scratchpad doc test: only Scratchpad is created synchronously; the
    AI Summary doc is created asynchronously, so stop asserting it (and
    the removed "Findings" grouping) at creation time.
  * AI-summary completion test: the AI Summary doc row now hangs under
    the thread heading ("Initial investigation"), not a "Findings" group.
  * remove the two "Add to document" tests — that control was removed.

* Fix stylelint rule-empty-line-before in explorations CSS modules

Add blank line before nested &:hover rules in ActionToolbar.module.css
and ExplorationGroupVisualization.module.css. These surfaced in fe-lint's
lint-css step once the oxfmt formatting failure was resolved.

* - cleaned up migration file
- Improve group renaming in code
- preserve starts between re-runs like comments

* Fixing CI failures

---------

Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>

* Don't send empty messages to the LLM

* address review comments from 76755

* UXW-4765 Hide charts (#77488)

* UXW-4765 Hide charts

* Review fixes

* Fix the failing explorations unit tests and Loki stories  (#77564)

* Fix the failing explorations unit tests and Loki stories at the branch tip

* Restore the original all-hidden copy and drop thread headings from it

* Delete the outdated ExplorationGroupVisualization stories and their Loki references

* Rephrase the hide toast to put the chart name first

* UXW-4764 Remove interestingness badges on results (#77544)

* UXW-4764 Remove interestingness badges on results

* Drop e2e assertion for the removed sidebar interestingness markers

* Colocate the interesting and error markers with their only consumers

* Remove the interestingness marker from the timeline picker too

* fix types, lints, formatting

* Uxw 4779 update tab menu to match designs (#77543)

* UXW-4779 Update sidebar tab menu to match designs

* Show a pointer cursor across the whole SegmentedControl

* Give the icon tabs img semantics and hide the decorative icon names

* Let getExplorationSidebarTabsInfo own the tab icon config

* Revert the SegmentedControl root pointer cursor, Mantine already puts it on the labels

* Regenerate modules config after master merge

The merge left 'explorations' and master's new 'entity-retrieval' out of
sorted order, which metabase.core.modules-test asserts on.

* Float the action toolbar pinned to the bottom of the chart canvas (#77667)

* fix types

* revert timelines in documents

* Review fixes: stop requiring the QP's internal cache.impl from other modules

Explorations code that saves and loads query-result snapshots was requiring
metabase.query-processor.middleware.cache.impl directly — an internal
namespace of the query-processor module that other modules aren't supposed
to reach into. Since the module's :api set is generated from actual usage,
those requires had put cache.impl on the module's public surface — something
master never exposed. Modules talk to each other through a small public
facade (metabase.query-processor.core) instead.

The two helpers those callers need — do-with-serialization (write a QP
result as a gzipped blob) and with-reducible-deserialized-results (read one
back) — are now re-exported from the qp.core facade, and the explorations
callers go through it. One consumer can't: queries/cached_result.clj is
itself loaded while qp.core is loading, so requiring the facade from there
would be a circular-load error at boot. It gets a proper home instead — a
new small, documented public namespace,
metabase.query-processor.result-serialization, re-exporting the same two
helpers. With nothing outside the module requiring cache.impl anymore, the
regenerated :api drops it, matching master.

* Review fixes: explorations planner and AI-summary polish

- Nested cardEmbeds: the AI-summary postprocessor wrapped only top-level
  cardEmbed nodes in resizeNode containers, while the card materializer
  walks the whole document — so a cardEmbed the LLM emitted inside e.g. a
  blockquote got a real card but rendered collapsed to zero height. The
  wrapper now walks the tree too (skipping already-wrapped nodes).
- Awareness-tier sort validation: phase 2 validates that embedded
  categorical charts carry an explicit sort instruction, but the validation
  set was built only from top-tier breakouts — an awareness-tier categorical
  chart escaped validation entirely, and its prompt block (slim-block) never
  told the model a sort was required. Both tiers are validated now and
  slim-block emits the same per-chart sort directive as full-block.
- Contextual-scoring gate order: safe-score+describe resolved its scoring
  context (a Card select and a SQL compile) before checking whether the row
  is scorable at all (chart config present, creator known, non-blank thread
  prompt) — wasted DB reads and a query compile for every unscorable row.
  The cheap gates run first now. Its docstring also claimed the LLM gate
  throws and is swallowed; it actually returns nil from a pre-flight cond.
- Discovery-cache TTL: the top-K category discovery cache was a
  process-lifetime LRU, so a stale top-K result could be served forever on
  a long-lived instance; entries now also expire after 15 minutes.
- Chart-name i18n: filtered-subset chart names built their multi-value
  summary as (str (count values) " values") — a hardcoded, unpluralized
  English fragment inside an otherwise-translated name. It uses trun now.
- Docstring/comment corrections: effective-cardinality documented (and
  branched on) a :num-bins bucket that no caller can produce — branch and
  docs removed; run-one-plan-iteration! said it runs "the LLM planner" when
  the default is the mechanical one; a variants-test section comment claimed
  temporal variants add a not-null filter that no variant adds.

Also repoints this module's result-blob serialization calls at the qp.core
facade (the consumer half of the cache.impl cleanup in this stack's first
commit).

* Review fixes: explorations API permissions, documents, and search

- Creating an exploration accepted arbitrary card ids (in blocks) and
  timeline ids without checking the caller could read them, so you could
  attach someone else's private metric or timeline and see its name/events
  later. Both are read-checked at create time now, and timeline hydration
  filters out timelines the viewer can't read. A duplicated timeline id used
  to hit a unique constraint and 500 the request; ids are deduped.
- The restart endpoint re-selected the Exploration row after resetting the
  thread, but the reset never touches that row — the redundant select is
  dropped.
- GET /api/document listed documents that belong to exploration threads,
  which search, recents, and collection items all deliberately hide. It
  applies the same filter now — which as a side effect removes an N+1
  pattern where each listed doc could trigger several permission queries.
- The document validator rejected {:type "doc" :content []} — the exact
  empty document the backend itself creates for new threads. An empty root
  doc is valid now.
- Search: is_published was deliberately hidden from search responses on
  master (#66841) and this branch accidentally re-exposed it — hidden again
  in both search engines. Exploration descriptions are searchable in the
  legacy engine too, matching the new engine.
- Data picker: metrics and dimension groups now use the same
  interestingness filter (a metric could previously reference dimension ids
  that appeared in no group), search matches the displayed
  "<group> - <dimension>" text, and a collection-ids query that ran twice
  per request runs once.
- Consistency: the create event publishes after the transaction commits
  (observers could previously fire before the data was visible).

* Review fixes: metabot / LLM infrastructure

- Asking Claude for extended thinking while also forcing a tool call
  (tool_choice "required") produced a request Anthropic rejects with a 400.
  The guard that already handled this for structured output now covers the
  general tools path too.
- The e2e guard that stops tests from hitting a real LLM provider threw a
  raw MalformedURLException on unparseable URLs; it now fails closed with
  its normal friendly message — and it was moved out of a catch-all that
  would have rerouted it, so it fails loudly.
- The timeline tools handed the LLM broken XML (an attribute quote and a tag
  never closed) and interpolated user-written names/descriptions unescaped,
  letting content break the markup. Output is well-formed and escaped.
- Hardening/docs: the retryable-error check bounds its exception cause-chain
  walk at 10 (a cyclic getCause could loop forever); the
  contextual-interestingness response schema now really forbids extra
  properties (its docstring claimed it already did); docstrings about
  thinking model buckets, return shapes, and inputs corrected.
- Also repairs three test breaks that already existed at HEAD: an arity
  mismatch from the OpenRouter merge, a test asserting thinking blocks are
  dropped (they now stream as reasoning parts), and tests that didn't stub
  the LLM gate's newer usage/permission checks.

* Review fixes: lib-metric, metrics transforms, startup sync, interestingness

- group-by-source was re-exported for JVM Clojure but missing from the
  ClojureScript branch of lib-metric.core; added.
- Metric/measure display-info inherited a default :long-display-name even
  where the method overrode :display-name, so the two could disagree;
  they're set together now.
- A failure in the startup metric-dimension sync aborted server boot
  entirely; it's wrapped to log an error and continue.
- The interestingness "leftovers" backfill loaded every unscored field for
  the database into memory in one select; it now streams one field at a
  time, and the namespace docstring mentions this second recompute trigger.
- Added a unit test covering the dimensions :out transform (kebab-key
  renames + type-value keywordization).

* Review fixes: enterprise perms/sync/deps, cached card query, test quality

- Remote sync: an event payload that simply omitted a key used to satisfy a
  nil condition (e.g. "exploration_thread_id must be nil") — conditions now
  require the key to be present with the right value, checked with
  (find object k), which distinguishes a present-but-nil entry from an
  absent key. Vector values in removal conditions rendered as broken
  equality clauses; they render as IN.
- Dependency graph: smartLink nodes with nil/placeholder ids added nil to
  the dependency set (the sibling cardEmbed branch already guarded this);
  guarded, with regression tests for both branches.
- Test quality: the revisions exclusion test asserted the impl against its
  own private set, so it could never fail; it checks a hardcoded expected
  list now. Exploration bookmarks get real API coverage
  (create/list/delete/ordering/archived), and a mislabeled test string was
  fixed.

* Review fixes: v4 instance-analytics views with explorations usage sources

The AI-usage analytics views translate machine source names ("exploration",
"contextual_interestingness", …) into human-readable labels with a SQL CASE.
The explorations stack added its new sources to the v1 SQL files — but the
views actually installed on instances are v3, and nothing reinstalled them,
so users saw the raw machine names fall through the ELSE branch.

Adds v4 of ai_usage_log and metabot_conversations (v3 plus the new CASE
branches, with one consistent "Explorations" label — v1 said "Exploration"
for the source but "Explorations" for the profile), a migration that
reinstalls the views at v4, an updated pointer comment in usage.clj, and a
parity test that fails whenever a known source/profile is missing a CASE
branch in the latest views — exactly the drift that happened here.

* Review fixes: frontend (minimal) and e2e

Only the low-risk frontend fixes from the review pass, plus the e2e test
fixes; the other frontend findings are deliberately left out to keep this
branch's FE diff small.

- getDimensionIcon existed byte-for-byte in two files; the duplicate module
  is deleted and its one importer now uses the copy in
  common/utils/columns.ts.
- e2e: an assertion that searched for text inside another text node (and so
  always passed) is meaningful now; readSelectedText no longer breaks
  Cypress's retry behavior by asserting after a .then; and the seemingly
  pointless GET /api/metric/:id in seedMetrics got a comment explaining the
  side effect it exists for (it persists metric dimensions).

* Remove the LLM query planner

Explorations plans queries with the mechanical planner only now. Removed:
the LLM planner namespace, its prompt templates (plan.selmer /
repair.selmer) and prompt loader, the prompt-markdown renderers in the
planner-context namespace, the explorations-query-planner setting
(mechanical/llm/auto), and the :skip-no-llm exit for "planner requires an
LLM but none is configured".

pick-planner! now always returns the mechanical planner — kept as a seam
so tests can stub it and a future planner has an obvious dispatch point.
The planner-contract protocol and the shared context builder stay (they
are planner-agnostic), and the query_plan_transcript column stays (every
planner writes a transcript); its remark no longer describes an LLM
exchange.

* remove graph._dimension_value_colors (per bar colors)

* Add row count to explorations API (#77740)

Already available, we just need to persist it and thread it through the
API.

* Format fix

* Exploration UX... um explorations. (#76901)

* click thru

* better comment links

* chart avatars

* avatars

* allow subsubexplorations

* disable explore further for Other group in top n charts and dimension anchored blocks

* fix explore further for binned numeric columns

* comment highlighting, remove avatars

* comment label formatting

* map and table highlighting

* use built in viz click actions popover instead of custom

* fix comment badge in dark mode

* self review cleanup

* fix and simplify new thread toast

* fix be lints, add tests

* fix tests

* add fe, e2e tests

* - POST /:id/explore-further now verifies the clicked page
  belongs to this exploration before copying its block, closing a hole where
  any page in the instance could be copied into any writable exploration.
- key on the stable :explore-filters spec instead of a hash of
  the per-row-reconstructed :dataset_query (which minted fresh :lib/uuids and so
  never hit). build-row-context exposes :explore-filters on the row context for
  the cache to key on.

Also reuse metabase.util/capitalize-first-char in the block title and thread
name helpers instead of hand-rolled str/upper-case.

* Code cleanup

* Gate exploration derived metadata by viewer's permissions

* fix lints

* Code review improvements

* Den/uxw 4741 explorations new sidebar (#77674)

* Update tabs view, add filter button menu

* Fix tests

* Add sidebar items vertical lines + icons

* Other sidebar changes - viewed state, sticky hierarchy

* Show sub-explorations near it's parent item

* Fix issues

* Self review

* Fixes after merge

* Self review

* Review fixes

* Fix empty states

* Review fixes

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>

* Explorations self-review cleanup (#77717)

* Consolidate the explorations migration in place

The 063 explorations migration had grown by accretion: tables were created
and then altered by later changesets in the same unreleased file. Since the
branch convention is to edit unmerged changesets in place, the final shapes
are folded into the createTables:

- exploration_query gains params, nullable dataset_query/entity_id, and
  page_id inline (the page_id FK and index stay separate — exploration_page
  is created later in the file, so they can't run earlier).
- stored_result gains data_access_token inline; its backfill changeset was
  deleted outright — created and backfilled in the same release, it could
  only ever run against an empty table.
- exploration_query_result gains chart_stats / metric_description /
  chart_description inline, which also retires the randomly-named changeset
  id v63.gr324asd.
- idx_eqti_query_id deleted: the unique constraint on the same leading
  column already provides that index.

The completed_at and analysis_started_at backfills are deleted outright:
the explorations feature merges to master in one piece, so at upgrade time
no instance can have pre-existing exploration threads to backfill. (They
were also dangerous — they stamped columns the cancel endpoint and the
completion handler gate on being NULL, permanently locking out any thread
in flight during the migration.)

Cosmetics: backfilled default docs are named "Scratchpad" to match runtime
(the backfill said "Findings"); stale remarks corrected (chart_stats is EDN
not JSON, the real query_type variant list, what collection_id actually
defaults to).

Existing dev databases will need a migration reset.

* Set exploration thread started_at in the INSERT

The thread was inserted without started_at and then immediately updated to
set it, on the theory that the background worker must not see the thread
before its dependent document/block/timeline rows exist. The whole create
shares one transaction, so intra-transaction ordering is invisible to the
worker (it reads on another connection; the commit is atomic) — the flag
can simply be set in the INSERT, saving a write.

* Guard exploration restart against in-flight work

The restart endpoint could fire while the planner was still working on the
thread, deleting its rows mid-plan and wedging the thread in a state no
worker would ever pick up again. reset-thread-for-rerun! is now a CAS: it
applies only to a terminal thread (completed_at set) with no query still
mid-execution, and the endpoint returns 409 ("cancel it before restarting")
otherwise. A successful reset leaves the thread exactly in the shape the
planning worker's claim predicate picks up.

The endpoint also published :event/exploration-update, which provably did
nothing: restart changes no Exploration column, and the revision system
skips unchanged objects. The publish is removed; the AI-summary doc reset
still records a Document revision, which is what /mine's last-touched
ordering composes.

* Route mid-stream LLM errors through the provider-friendly rethrow

LLM responses stream lazily; a connection timeout between SSE chunks
escaped as a raw SocketTimeoutException because the provider error handling
only wrapped request setup — the reduction happens later, in the agent
loop. Every provider adapter's SSE reducible (claude, openai, openrouter,
azure, bedrock) is now wrapped so consumption-time failures get the same
translation as request-time errors.

* Grant the read-only timelines scope under metabot-nlq

The explorations metabot profile instructs the model to use the timeline
tools, but those tools were granted only under
:permission/metabot-other-tools while the profile is gated on NLQ — so for
NLQ-only users they were silently filtered out of scope while the prompt
kept referring to them. agent:timelines:* (whose only member is the
read-only list/details scope) is now granted under both permissions.

* Don't retry deterministically-failing fields in the interestingness leftovers pass

The leftovers pass selects on dimension_interestingness IS NULL, so a field
whose scoring deterministically throws was re-attempted on every sync
forever. A per-process attempted-marker now skips fields that already
failed in this process; a restart re-enables retry, so transient failures
still converge. Process-local by design — no schema change, and no sentinel
score leaking into product surfaces that read the column as a number.

* Fail closed when a sandboxed field's user attributes are missing

sandbox-token-for-table returned nil when the field was sandboxed but the
user's sandbox-attribute lookup came up empty — and the downstream
compatibility gate reads nil as "unrestricted". That's fail-open. It now
returns a user-scoped "indeterminate" sentinel that matches nobody else's
token (two indeterminate users don't match each other either): fail-closed.

Also adds direct and end-to-end tests for the impersonation and routing
token producers, which previously had none.

* Require the (card, stored_result) pairing on the cached card-query path

POST /api/card/:id/query with a stored_result_id read-checked the card but
then served whatever snapshot id the client sent — nothing verified the
snapshot actually belonged to that card. The pairing must now exist in
stored_result_use (404 when it doesn't, so an unpaired probe can't confirm
a snapshot exists). Both materialization paths already insert the pairing
rows, and the table ships in the same unreleased release, so no legacy data
lacks them.

The viewer-lens check also denied a nil dataset_query only by accident (a
schema exception swallowed by the catch — and in prod, where mu/defn logs
instead of throwing, it would have failed open); it denies explicitly now.
Plus: consistent t2/select-one forms in the endpoint, a stored_result_use
docstring update, and the generated module config picking up the new
StoredResultUse consumption.

* Apply the personal-collection default when it resolves after first render

The "default new explorations to my personal collection" logic captured
personalCollectionId once in a useState initializer — if the selector
resolved after the first render, the default never applied. It now
reconciles when the id arrives, without overriding an explicit user choice.

* Move the explorations migrations to v64

The v63 release branch has been cut, so the explorations feature will ship
in v64 and its migrations belong in the 064 directory. Both files move
(20260428_explorations.yaml and 20260519_exploration_bookmark.yaml) and
every changeset ID is reprefixed v63. -> v64.

No changeset content changes. Since we're already editing unreleased
changesets in place on this branch, existing dev databases needed a
migration reset anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v_metabot_conversations v4: restore include-deleted semantics lost in merge

Master's #77542 (conversation branches) and this branch both created
metabot_conversations/v4 in parallel: master's dropped the
deleted_at IS NULL filters so soft-deleted (rewound/regenerated) messages
are counted; ours added CASE branches for the new explorations usage
sources and profile. The 'Merged from master' resolution kept only our
copy, silently losing the deleted-message semantics — which is why
deleted-messages-included-test and last-message-at-test fail: they assert
master's behavior against our view.

This drops the three filter lines from each dialect's v4 file so v4 is the
union of both changes. Both installing changesets (our v63 and master's
v64) are runOnChange: true and checksum the referenced file, so every
instance reinstalls the corrected view on upgrade — no new version or
migration needed.

* Drop the redundant v_metabot_conversations v4 installer

Master's #77542 (064/20260713_metabot_analytics_views_include_deleted.yaml)
already installs metabot_conversations v4 via a runOnChange changeset against
the same SQL files, which now carry both sides' changes: the explorations
CASE branches and the include-deleted semantics. A second installer here was
redundant — and would have collided with master's changeset IDs
(v64.2026-07-13T00:00:00/01) had this file been mechanically renumbered to
v64 like the other explorations migrations.

The ai_usage_log v4 changeset stays: nothing on master installs that view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move the ai_usage_log v4 migration to 064

Same reasoning as the explorations tables: v63 is cut, this ships in v64.
The changeset ID takes the next free slot for its date
(v64.2026-07-13T00:00:02 — master's include-deleted file owns 00 and 01)
rather than a mechanical v63->v64 rewrite that would have collided.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Show a crossed-eye indicator with a tooltip on hidden pages in the exploration sidebar (#77907)

* Explorations no AI Summary (#77878)

* Extract AI Summary + document-in-exploration out of explorations

Removes the LLM-generated "AI Summary" feature and the ability to display a
document within an exploration. The feature-restoring (inverse) commit is kept
on branch `explorations-ai-summary`; cherry-pick it to bring the functionality
back.

Backend:
- Delete the explorations.ai-summary.* pipeline, composite, document-perms,
  prompt templates, and their tests
- Drop exploration->Document creation, the /thread/:id/documents endpoints,
  the :documents hydrate, ai_summary_document_id, and the AI-summary reset
- Revert the documents-module coupling: content-visibility gate, the
  document.exploration_thread_id FK/column, the view-log skip, and the
  GET /document list filter; delete documents/validate.clj
- Drop the collection-items show_exploration_documents option and the
  exploration-doc exclusions in recents / search / serdes / remote-sync;
  drop the search exploration-thread-id attribute
- Remove the ai_summary_document_id and document.exploration_thread_id
  migration changesets (edited in place)

Frontend:
- Delete the ExplorationDocument component and its tests
- Remove the AI-summary sidebar node, completion toast, and the document
  API / urls / types; keep the shared comments sidesheet and page routing

* Extract static card-embed in documents out of explorations

Removes the static (cached-snapshot) card-embed capability from documents, so it
can land on its own branch (`static-embed`) independently of the AI-Summary
feature. The feature-restoring (inverse) commit lives on `static-embed`.

Keeps the base `stored_result` model, the exploration query-result cache, and the
cached-result permission helpers (`assert-can-view-cached-result!`) that the
exploration `/query/:id` streaming endpoint uses. Also keeps the general
`report_card.document_id` "card in a document" feature (it predates this work).

Removed:
- The cached-result serve path (`cached-dataset`, `apply-sort`,
  `deserialize-cached-result`, `allowed-chart-sorts`, `viewer-can-view-cached-result?`)
  and the `stored_result_id`/`sort` branch of `POST /api/card/:id/query`
- `stored_result_use.card_id` (column + model arm; keeps `exploration_id`)
- The documents serdes static-node filter (reverted to master's live-only rewrite)
  and the prose-mirror static docstrings
- Frontend: the tiptap `cardEmbed` static rendering (the `stored_result_id` + `sort`
  + `chart_href` trio) across the CardEmbed node/menu, document hooks, redux, and
  `card.ts`; deleted the CardEmbedNode static unit spec

* Fix pre-existing cljfmt/oxfmt formatting to unblock CI

These 4 files (a stray blank line each) are unchanged from the base explorations
branch — the format checks were failing on inherited, not extraction, changes.

* more documents cleanup

* Update Loki Snapshots

* change references from entity (which was page or document) to just page

---------

Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>

* Restore fix

* Fix Metabot exploration tools streaming empty results to the client (#77990)

Since 6912a50e85 ([Metabot] Chat internal format improvements, #77301)
only a tool result's :output string makes it onto the tool-output-available
SSE event; a bare map streams as "". The exploration plan tools returned
bare maps, so the FE — which applies plan edits by parsing the streamed
tool result — silently dropped every edit while the LLM (which still saw
the full result) reported success.

Wrap each tool's payload in {:output (json/encode ...)} and add a
regression test that runs real tool results through parts->aisdk-sse-xf
and asserts the payload survives onto the wire.

* Explorations timeline fixes (#77958)

* fix timelines

* disable timelines for row charts

* Explorations sort order (#77980)

* sort empty pages to bottom

* sort subexplorations by the initial exploration's interestingness

* Add e2e tests

* Explorations use persistent queue (#77901)

* explorations: run planning/queries/scoring on the persistent queue

Switch explorations from the hand-rolled polling runner to the mq persistent
queue: three queues (plan, query, timeline-score) with per-queue on-error
handlers and worker-count-bounded concurrency.

The generic mq subsystem changes this builds on live in a separate branch
(mq-queue-improvements) so they can land on master independently. Until that
lands and flows back into `explorations`, this branch depends on those mq
features (:on-error, :max-concurrent-batches) and won't run standalone.

* Remove explorations settings-test

* Code cleanup

* Code cleanup

* Use in-memory queue backend for e2e tests

The persistent Quartz queue backend stores its durable-job state in the app
DB's QRTZ_* tables. e2e restores app-db snapshots between tests (H.restore),
which drops those tables out from under the running scheduler while the
per-process ensured-jobs cache still believes the jobs exist. Post-restore
publishes then target a non-existent job and never deliver, so exploration
planning produces no queries and the detail-page sidebar stays empty.

e2e is a single node, so the in-memory backend (no app-db state, unaffected by
snapshot restore) is the correct fit. Set via the :e2e alias so it applies to
both local (runFromSource) and CI (runFromJar, via getJvmOptsFromDepsEdn).

* fix type

* Remove Timeline interestingness from explorations (#78013)

* Remove Timeline interestingness from explorations

Removes the LLM-driven per-(query, timeline) interestingness scoring
feature and all of its wiring

* Improve names (#77862)

* improve names

* review changes

* fix dimension disambiguation not working

* Fix explore-further on expression columns (#77900)

1. The 500 — normalize-target-ref (in metrics/transforms.clj) silently dropped the expression name. The :field ref schema has a legacy-form decoder that relocates the id into MBQL-5 position; the :expression schema has none, so a
  valid legacy ["expression" "Gear"] normalized to [:expression {…} nil]. That nil-named ref then failed Malli validation inside find-matching-column mid-request → 500.
  2. The drill couldn't work anyway — a top-n-other chart breaks out on a synthetic CASE expression that exists only in the variant query, never on the metric card. So even a valid expression ref could never resolve against the card.

* Code cleanup

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>

* Update Loki Snapshots

* Improved LLM error handling

* Fixed queueng of explore-further

* Fix e2e tests

* Add copy link hotkey support

* Fix e2e tests

* Fix CI

* Fix after merge

* Make highlighted state in line charts more obvious when hovering over comment badge (#77998)

* show tooltip when highlighting comment for cartesian charts

* set blurScope to blur across stacked chart grids, only show tooltip for single line series

* review feedback

* add types file that was supposed to be included with 77998

* self review

* Auto-switch the add metrics modal to the All tab when the library has nothing to show (#78070)

* Fix back button (#77995)

* Fix back button

Co-Authored-By: Andrei Chernykh <a.d.chernykh@gmail.com>

* Remove the stray /question/explore route, fix the entry spec's removed setMode prop, and clean up dead code

* Keep the research draft and conversation alive across the entry/plan step and browser back/forward via a shared provider on the research route

* Fold the plan-step URL assertion into the existing manual-flow e2e test instead of a separate one

---------

Co-authored-by: Ethan McCue <ethan@mccue.dev>

* Explorations: remove dormant extended-thinking support; unify LLM timeouts (#78173)

* Remove unused Anthropic extended-thinking support

Extended thinking was fully dormant: the Claude adapter supported it
(streaming translation of thinking blocks, config normalization, request-body
support) and call-llm-structured-with-trace forwarded a :thinking opt, but no
production caller ever passed :thinking, and the multi-turn agent path
(call-llm) never accepted it at all. Remove the adapter support and the
structured-path plumbing until a real consumer exists.

This also resolves several open review questions about the thinking code
(signature round-trip on multi-turn continuations, structured-output
guarantees under thinking, per-model capability guards) by removing the code
they applied to.

* Unify LLM HTTP timeout knobs across metabot.self and llm.anthropic

metabase.metabot.self.core/request hardcoded its connection (10s) and
socket (120s) timeouts, diverging from the metabase.llm.anthropic HTTP
path, which already reads the operator-tunable llm-connection-timeout-ms /
llm-request-timeout-ms settings. Operators had two uncoordinated knobs.

Delete the two private constants and read the settings at call time in
`request` (keeping the per-request `(merge req)` override). Raise the
setting defaults to the former constants' values (5000 -> 10000,
60000 -> 120000) so no path loses its safety margin, and move the
constants' streaming/inter-byte rationale into the defsetting docstrings.

Both LLM HTTP paths now share a single operator knob pair.

Resolves PR #1 review comment 2.

* UXW-4758 Ability to view all timeline events

* Show proper empty state message in sidebar for initial load

* Bump prefer-with-dynamic-fn-redefs ratchet to 28

The explorations MQ queue tests need three real with-redefs (not
mt/with-dynamic-fn-redefs): the handlers run on an MQ worker thread that
does not carry the calling thread's dynamic bindings, as documented
inline at test/metabase/explorations/queues_test.clj:102,153,265. Seed
the budget to match so metabase.core.kondo-ratchet-test passes.

* fix regression of #77667 caused by #76901

* Restore explorations CASE branches in v_metabot_conversations (v6)

master's #77549 rewrote v_metabot_conversations to v5 (summary->title
rename) without the explorations source/profile CASE branches that v4
carried, so the exploration/contextual_interestingness sources and the
explorations profile surfaced as raw machine names and failed
metabase-enterprise.metabot.usage-test/known-sources-and-profiles-have-view-case-branches-test
across every dialect.

v5's installer is not runOnChange and is already released on master, so
edit it in place would break the checksum. Instead add v6 (v5 + the three
branches) and a runOnChange installer that reinstalls the view after the
v5 rename, matching the v4 installer pattern.

* fix errors caused by merging master

* UXW-4758 Close timeline events sidebar when comments are opened

* Explorations queries api performance (#78175)

* revert error message display changes

* Avoid a full fingerprint version increment

* Fix unit tests error logs

* Removed the composed LRU + TTL cache that is not supported by the core.cache factories. The data should be naturally bounded enough that TTL should be good enough.

* Added scopes for explorations in metabot

* Explorations PUT safety (#78188)

Don't allow random assignment on PUT /api/exploration/:id

* Pick the right column as the metric for interestingness scoring (#78269)

Pick the right column as the metric

* Explorations unstranded threads (#78250)

Fixing ways threads can be stranded

* Hide SQL error messages from API response

* Explorations pass research plan (#78236)

Correctly pass research plan

* Revert in-place edits to historical instance-analytics view SQL

The explorations CASE branches were added directly to the v1/v4 view SQL
files, but those files back runOnChange changesets: editing them changes
the checksum, so on an already-migrated DB the old installers re-fire.
metabot_conversations v1 still selects c.summary, which the v64 rename
(summary -> title) has dropped, so booting the branch on a master-migrated
DB fails with 'column c.summary does not exist' (all six cross-branch
migration CI jobs).

The branches live on in the branch-new final versions (ai_usage_log v4,
metabot_conversations v6), so nothing is lost on fresh installs.

* Raise prefer-with-dynamic-fn-redefs ratchet to 29

The branch added four with-redefs ignores in explorations/queues_test.clj
(all justified: the redefined handlers run on MQ worker threads, which do
not see thread-local dynamic bindings, so mt/with-dynamic-fn-redefs cannot
work there) but the budget was only bumped to 28. Verified all 29 ignores
in the tree still sit on live with-redefs calls, so none can be removed
instead.

* Improving exploration error handling

* Fix with-current-user binding in exploration runner (#78163)

* Better error vs. empty handling in query cache

 Conflicts:
	src/metabase/explorations/query_plan/variants.clj

* self review

* Added FKs

* fix e2e

* Added visible-metric-cards-where-clause to queries.core

* Cleaned up removed-in-master agent:table scope

* Reuse Event card design from QueryBuilder

* Handle prompt injection

* Fix hotkeys hijack default actions

* Reuse the query instead of continually re-running it

* Fix extra rerenders on queries polling

* Review fixes

* Review fixes

* Fix chart skeleton for all screens

* review comments

* Fix Andrey's review comments

* Fix Andrey's review comments

* Fix Nemanja's review comments for e2e

* Fix Nemanja's review comments for e2e

* more review comments

* Ensure user has read permissions on cards when doing an explore-further

* Handle numeric PKs/FKs better in explorations (UXW-4757) (#77991)

Numeric PK/FK dimensions produced unusable one-bar-per-id charts and were
auto-selected as interesting dimensions. Two root causes:

1. The query planner treated any numeric dim as auto-binnable, but...
   they're not, and we emitted unbounded bare breakouts, and ruled out
   the top-n-other variant.

2. Dimension interestingness hard-zeros PKs but not FKs, and the
   cardinality scorer's auto-binnable branch gave numeric FKs 0.9 for a
   binned chart the QP can never produce, so they got auto-selected.

Fixes:

- default-bucket-for-dim returns nil for :Relation/* dims, so numeric keys
  band by raw distinct count: <=100 -> default, >20/unknown -> top-n-other.
- top-n-other stringifies its CASE THEN values so numeric dims compile to
  a single-typed CASE (mixed numeric/string CASE breaks strict warehouses).
- type-penalty hard-zeros numeric FKs (semantic FK + numeric effective/base
  type). Non-numeric FKs (e.g. a country-code FK) stay scoreable.
- The cardinality scorer excludes :Relation/* from the auto-binnable
  branch, matching lib's own binning rule.

* UXW-4475: self-heal missing metric dimensions in explorations read path (#78061)

Metrics whose dimensions were never successfully synced (dimensions NULL) -
e.g. metrics created on a model before it had result_metadata, or SQL-model
metrics predating the source-card sync fix - showed zero dimensions in the
explorations picker and Metabot research tools forever, because those paths
read persisted dimensions without ever syncing.

hydrated-metrics now runs sync-dimensions! for loaded metrics that have a
dataset_query but empty dimensions, then reloads the healed rows. Metrics
that compute nothing persist nothing and retry cheaply on a later request
(app-DB reads only, no warehouse queries, no write when nothing changed).

* Fix stored-snapshot permission gate ignoring card-sourced queries (#78455)

The data-access-lens capture derived its table set from
query->source-table-ids on the raw stored dataset_query, which only
collects numeric :source-table ids. For queries built on cards
(:source-table "card__N"), neither the creator's nor the viewer's token
picked up any tables, so both sandbox dimensions were empty and
data-access-compatible? returned true — letting a sandboxed viewer
stream an unsandboxed creator's snapshot.

Add query-perms/query->resolved-source-table-ids, which preprocesses the
query (as admin, via the existing preprocess-query helper) so source
cards — nested chains and card-sourced joins included — resolve to their
underlying table ids, and use it at both lens-capture sites (the
exploration runner's token write and the cached-result read gate). The
preprocess binds qp.i/*skip-middleware-because-app-db-access*, the same
flag the offline semantic checker uses, so the routing/impersonation
preprocess middlewares don't resolve the *viewer's* destination/role
(and throw on lapsed premium features) while we're only after the
query's table footprint. The helper throws when the card chain can't be
resolved (deleted card); non-admin viewers are then denied while
admins — never sandboxed, impersonated, or routed off the router db —
fall back to the same admin-only access as a nil token.

Also make stored_result.dataset_query NOT NULL via a follow-up
addNotNullConstraint changeset: the gate derives the viewer's lens from
it, so a query-less snapshot has no defensible verdict. A gate caller
passing a map without the key now gets a loud exception (500) instead of
a quiet fallback, so trimmed-row bugs surface in development.

Also fix explore-further-nested-thread-name-omits-metric-prefix-test to
reference a real exploration_page instead of a hardcoded id 999 that
violates the source_page_id FK on fresh databases.

* Fix build

* Fixed comment

* Better cached result testing

 Conflicts:
	src/metabase/queries/cached_result.clj

* Hash data access token info

* Added test from before

* Fix unit tests

* Fix Andrey's review comments

* Sloan review comments

* Explorations - fix metrics case (#78411)

* Add delay for Sidebar shimmer text, split ExplorationTreeNode

* Fixes for routing after merge

* Latest reviews fixes

* UXW-4908 new Comments UI

* add more analytics

* Code review fixes

* Fix scroll to view for focused comment

* Correct permissions "lenses" (#78697)

* Correct permissions "lenses"

Some early code had a huge, obvious bug, asserted in tests, for database routing.

For explorations, because queries are pre-computed, we need to calculate "lenses" representing the state
of database routing, sandboxing, and impersonation. These lenses are calculated when the exploration is
created, and then calculated when someone attempts to view the exploration. The idea is that if the lens
reveals that the viewer would have been able to view the underlying queries, then they should also be able
to view your exploration.

Until now, a viewer who was "unrestricted" on one of those dimensions was allowed to see a restricted
snapshot. I.e., a "non-sandboxed" user could see a sandboxed user's exploration, a "non-impersonated" user
could see an impersonated user's snapshot, and a "routed-to-the-router" user could see a "routed-to-a-destination"
user's snapshot.

The issue here... "unrestricted" does not necessarily mean "unrestricted." It's entirely possible that:

- User A is sandboxed with a sandbox that replaces Table T with card C, which actually queries Table FT.
  User B is permitted to see Table T (unrestricted) but is blocked on Table FT.

- User A is impersonated - to a *more* permissive role. User B is unimpersonated, but should not be able to see
  the results of a query made by User A.

- User A has database routing, configured to point to `MySecretTenantData`. User B is routed to `__METABASE_ROUTER__`,
  which holds a template schema and maybe some data to play around with. User B should not be able to see the results of
  A's exploration.

This simplifies the code here:

- superusers can see every exploration, regardless of lens

- everyone else sees an exploration's results *ONLY* when their data-access lens is *identical* to the creator's. Same sandbox
  (and relevant user attributes), same impersonation role, same routing destination. Unrestricted only matches unrestricted.

Also hardened the write side: capturing the creator's lens fingerprint used to be best-effort, silently saving a snapshot with
no fingerprint when it failed. Now the exploration query fails visibly instead, and the read side treats a
fingerprint-less snapshot as a bug (denied to non-admins, logged as an error) rather than something to adjudicate.

* Apply suggestions from code review

clean up test descriptions and other docstrings

Co-authored-by: Timothy S. Dean <timothy@timothydean.dev>

* Respond to CR

* fix impersonation test

---------

Co-authored-by: Timothy S. Dean <timothy@timothydean.dev>

* Fixed compile issue

* Address query-plan review comments on #73244

- context.clj: correct the column-fingerprint-for-target docstring (missing
  fingerprints make default/time-facet eligibility fail closed and route
  dims to top-n-other, not the reverse), fix the applicability return-shape
  doc, and extend the ns docstring to cover build-row-context.
- context.clj + variants.clj: remove the dead temporal_dimension_id override
  plumbing (nothing ever sets that param; the mechanical planner never emits
  per-value-time-series items).
- variants.clj: drop the unreferenced known-variants var.
- query_plan.clj + planner.clj: validate every planner result against
  result-schema in the orchestrator, treating a mismatch as a planner
  failure, and document that the schemas are enforced.
- planner.clj: fix the plan! docstring's claim that the ctx shape is
  documented on generate-query-plan!.

* Remove unused planner result schemas instead of enforcing them

Drops plan-item-schema and result-schema from the planner contract (review
follow-up on #73244): they were never applied to any value, and rather than
wiring validation into the orchestrator we document the plan!/result shapes
in prose on the protocol. Also rewords the context ns docstring's
plan/validate/materialize mention accordingly.

* Trim redundant clause from query-plan.context ns docstring

* Fix FE typecheck and unit tests

* Fix some tests

* Fix dimensions display in NewExplorationData

* Select all dimensions for a metric by default

* Fix metric-dimensions dependency test vs metric dimension auto-sync

The test (from master) created a metric card with hand-written
dimension_mappings and nil dimensions. On this branch a metric with nil
dimensions counts as uncurated, so the after-insert dimension auto-sync
seeds dimensions from the query and overwrites the provided mappings —
the mapped categories table never became a dependency.

Set the mappings via a plain update after insert instead: the
after-update hook only re-syncs when dataset_query changes, so they
stick, and the test still exercises what it means to — the dependency
backfill turning mapped-dimension tables into table dependencies.

* Fix metric dashcard perms test vs metric dimension auto-sync

The tests (from master) expect a metric dashcard query to return the
bare aggregation. On this branch the after-insert auto-sync seeds
metric dimensions, and qp.dashboard applies the default time dimension
as a breakout, turning the scalar count into a monthly series.

Suppress the auto-sync when creating the metric so the test stays
focused on what it exercises: permissions on the metric's source
model.

* Fix FE typecheck and unit tests

* Skip tests that will be addressed in the next PR

* Fix loki tests

* Add logs to identify e2e test failure reason

* Fix CI e2e failure

* Review improvements

* Redact raw driver errors from the exploration payload

`GET /api/exploration/query/:id` deliberately allowlists its 409 payload and
substitutes a generic message, because a driver error names tables, columns,
and row values from a query the reader may not have permission to run.

`GET /api/exploration/:id` hydrates the same `ExplorationQuery` rows onto its
response and returned `:error_message` verbatim, so the raw error reached
anyone who could read the exploration. Declaring the key in the response
schema does not help: a `defendpoint` response schema validates and encodes
but does not strip.

Give the substitution one home in `redact-query-error` and route both paths
through it — `hydrate-exploration` covers all five `::HydratedExploration`
endpoints at a single point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Track data_access_token with thread and query instead of relying on the (not always there) cached result

* Key the derived-data verdict on both query projections

* Remove eager dimension-sync for metrics

* Fix unit tests after merge

* Fix typecheck

* Code review fixes

* Revert "Select all dimensions for a metric by default"

This reverts commit d5e8883c95.

* Code review fixes

* Fixing tests

* Fix e2e test

* fix issues after merge

* fix error when saving exploration in Our Analytics

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Andrei Chernykh <andrei.chernykh@metabase.com>
Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: Timothy S. Dean <timothy@timothydean.dev>
2026-08-05 21:34:13 +00:00
Anderson
76a9f014d5 Don't auto-assign a default dimension when creating a new metric / Allow for removing default (#79409)
* Don't auto-assign a default dimension to new metrics

* Update remaining metric tests for the no-default-dimension behavior

* Allow removing a metric's default dimension

* e2e tests cleanup

* unit tests cleanup

* Fix metric source replacement expectation

* address PR feedback
2026-08-05 17:00:09 -03:00
Phoomparin Mano
cac4d1e44e refactor: reusable typed schema codegen module (#78223)
* refactor: extract reusable typed schema module

* remove obsolete database-name query parameter

* refactor: split typed schemas rest endpoint module

* refactor: move public facade to core namespace

* refactor: use bare namespace for typed schema endpoint

* docs: describe typed schema options

* refactor: narrow typed schema query parameter

* docs: explain typed schema database names

* docs: clarify typed schema model scope

* re-export render-typescript from core namespace

* refactor: tighten typed schema options

* docs: add docstrings to typed schema query parameters

test: clarify typed schema parser coverage

* refactor: isolate typed schema model scope

* docs: clarify typed schema model scope

* test: simplify typed schema model scope coverage

* test: simplify rest api tests

* test: separate typed schema API and core coverage

* test: cover typed schema database selection

* test: assert generated typed schema tables

* test: normalize typed schema timestamps

* refactor: introduce TypeScript AST between schema and rendering

schema->ast is a pure policy stage producing a tagged-vector AST
(documented and malli-schema'd in typed-schemas.javascript); render-js
is an option-free printer over it. Replaces print-time policy callbacks,
path-based node-kind guessing, and the reference magic-map. Output
verified byte-identical against the previous renderer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: split typed schema fetching from pure assembly

fetch-items is now the pipeline's only impure stage; create-schema
assembles Items into the semantic schema value and takes an info map so
generated-at/instance-url can be pinned for deterministic output.
build-semantic-schema composes the two, unchanged for callers. Folds
the base-schema namespace into core, drops the duplicate
database-id-filter-clause, and rewrites core tests against literal
items. Output verified byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: reify typed schema data access as a protocol

SchemaSource names every read fetch-items performs; app-db-source is
the production implementation. Tests reify the protocol with literal
values instead of redefining selection functions across namespaces.
Output verified byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* test: add real-db coverage above the in-memory pyramid

source-test exercises app-db-source against with-temp rows — the one
seam where typed schemas meet the database. core-test gains a single
end-to-end test running every entity kind (question, metric, model with
action, table with fields) from with-temp rows through fetch, assembly,
AST and rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* test: run the pipeline e2e against the test-data dataset

mt/dataset provides a real synced database, so the e2e cards sit on
real tables and fields — the question and metric need no hand-written
result_metadata, and metric columns are computed from real metadata.
Replaces the hand-rolled temp database/table/field rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* test: assert the e2e schema with =? pattern matching

One structural pattern replaces the key-plucking assertions; a single
exactness check keeps the database-scoping guarantee that partial map
matching would silently lose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* test: sweep typed schema tests into =? pattern style

select-keys plucking becomes =? patterns (sets stay exact under =?, so
scope assertions keep their meaning); try/catch exception capture
becomes (is (thrown? ...)) + =? on ex-data; arg-asserting mocks become
stubs, with the argument pass-through now covered by the dataset e2e;
vestigial #' derefs on now-public fns are dropped; the five
prefer-with-dynamic-fn-redefs lint warnings are fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* docs: state the module's separation rules in namespace docstrings

Future changes should keep the boundaries: reads go through
SchemaSource, stages downstream of Items stay pure with environment
values entering via info, render policy stays schema->AST, TypeScript
syntax stays in the printer, and REST strings decode once at the
endpoint boundary. core-test documents where new tests belong in the
pyramid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: drop convenience arities orphaned by the source protocol

app-db-source always passes full arguments, so the single-argument
arities of select-schema-cards, select-tables, question-schemas,
metric-schemas and model-schemas had no production callers left. Each
fetch function now has one full-arity signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: define scopes and trim the library scope to its live keys

scope.clj now defines the vocabulary: references are portable ways to
name content (ids, names, entity ids); a scope is what they resolve to
on this instance — permission-filtered id sets with descendant
expansion, nil-vs-empty semantics, and the 404-vs-empty asymmetry
stated. LibraryScope gets a closed malli schema; its :collection-ids
and :library-collections keys were computed and asserted but read by
nothing, and are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: move scope compilation to the fetch layer as scope-filter-clause

scope.clj now only resolves references into scopes; the fetch layer's
schema.common owns the one function that compiles a scope into a where
clause. The docstring states the nil-vs-empty enforcement and warns
against (seq ...) guards at call sites, which would turn 'resolved to
nothing' into 'unscoped' and select everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: batch collection reference resolution in typed schema scopes

Adds collection/descendants-flat-for (descendants of many collections
in one query, selecting :type) and rewrites scope resolution on top of
it: references partition into id and entity-id batches, resolve in at
most two selects, and 404s name every unresolved reference instead of
a bare 'Not found.'. Query count is now constant in the number of refs.
library-scope enforces its LibraryScope return schema via mu/defn.
Addressing, eligibility, and authorization are distinct layers in
resolve-collection-refs!, and their failures are deliberately
indistinguishable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: shrink the typed-schemas public surface to its contract

core now exports exactly three vars — SemanticSchemaOptions,
build-semantic-schema, render-typescript — mirroring the module's
data/print split; the pipeline implementation moves verbatim to the
internal pipeline namespace. New exports are compatibility promises,
and nothing needed the other five. The endpoint returns a ring map
directly instead of calling respond (async comes free), and
database-ids-for-ref uses select-one on the id path, documenting that
name references can genuinely match several databases (union semantics
pending a product decision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* refactor: define the public API in core and name the build side

core now defines build-semantic-schema and render-typescript as visible
compositions of the internal stages instead of potemkin re-exports —
the public API is deliberate and decoupled from internal names. The
internal namespace becomes 'build': it interprets options into
SchemaSource reads and assembles the items into the schema value,
pairing with render/javascript as the module's two sides. The
compositions move out of build and render accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

* test: build e2e card queries with Lib instead of deprecated mbql-query

The e2e's cards now carry lib-built MBQL 5 queries
((lib/query mp (lib.metadata/table ...)) + lib/aggregate) rather than
hand-rolled legacy MBQL via the deprecated mt/mbql-query macro — which
also proves the pipeline handles MBQL 5 dataset_query cards end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB

---------

Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 02:39:53 +07:00
Egor Grushin
be5707555d Make indexed columns prominent in index list; fix name ellipsis (#79454)
* Make indexed columns prominent in index list; fix name ellipsis

* Revert test change
2026-08-05 20:09:25 +01:00
Kevin Kelleher
1974d2b34a Fix legends causing chart layout shift (#79458)
render legend and chart both only when non-zero width and height are provided
2026-08-05 15:01:23 -04:00
Sloan Sparger
0d1a8948b7 [Metabot] Increase max_tokens for anthropic models (#79030) 2026-08-05 17:25:15 +00:00
Andrei Chernykh
0b550259ba chore: Convert metabase-lib/v1 operator and expression utils to TypeScript (#78968)
* Convert metabase-lib/v1 operator and expression utils to TypeScript

* Address review: reuse api Field/Table types, extract format-options type, narrow operator name/type unions, add explicit return types

* Address review: throw via checkNotNull instead of optional chaining in operators spec
2026-08-05 10:05:26 -07:00
Andrei Chernykh
b408a5b32f Keep the fixed-width dashboard header from collapsing next to an open editing sidebar (#79431)
* Keep the fixed-width dashboard header from collapsing next to an open editing sidebar

* Update frontend/src/metabase/dashboard/components/DashboardHeader/DashboardHeaderView.module.css

Co-authored-by: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com>

---------

Co-authored-by: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com>
2026-08-05 10:05:00 -07:00
Nemanja Glumac
62383e86b7 Drop dead QueryExecution delete from embed download test (#79453)
Drop dead QueryExecution delete from embed download test

with-query-execution! redefs save-execution-metadata!* and captures the
query execution in a promise, so nothing is written to or read from the
QUERY_EXECUTION table. The delete! only served to reach the app DB before
anything had initialized it, failing with "this database is empty".

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:31:49 +02:00
Arik Aronov
919523d744 Security advisory version range display (#78649)
fix advisory version range display

Co-authored-by: Nicolò Maria Mezzopera <nicolo.mezzopera@metabase.com>
2026-08-05 16:32:03 +01:00
Sloan Sparger
19de9cb9b5 [Metabot] Remove unused profile temperature setting (#79457) 2026-08-05 09:53:13 -05:00
Nick Fitzpatrick
33b58efb9f Restructure Database form to handle engine change internally (#78932)
* Restructure Database form to handle engine change internally

* PR Feedback
2026-08-05 10:49:14 -03:00
Nick Fitzpatrick
8d598a796d Break out RemoteSyncSettingsForm component into smaller chunks (#79379)
* break out RemoteSyncSettingsForm component into smaller chunks

* PR Feedback

* liting
2026-08-05 09:18:51 -03:00
Andrei Simionescu
607459c994 Persist the download format on export, never on format switching (#79363)
* Persist the download format on export, never on format switching

The download popover remembers your last-used export format, but it was
persisting that preference on every click of the format segmented control,
spraying identical PUT requests at
/api/user-key-value/.../download_format_preference while the user just
looked around.

Switching formats now sends nothing. The preference is written as part of
handling an actual download, a fire-and-forget side task on top of the
export work, and only when it differs from the value the server already
has (the widget fetches it anyway to preselect the format, and the
mutation keeps the cached copy in sync, reverting it if the write fails).
Downloading with an unchanged format sends nothing at all. User-visible
behavior is unchanged: the popover still reopens on the format you last
exported with. The downloads e2e spec asserted the old
selection-persists behavior and is updated to the new semantics.

Considered and declined in review: guarding the comparison against a
still-loading preference (a download racing the initial GET can send one
redundant idempotent write; ordering a fire-and-forget path is not worth
it), and serializing overlapping preference writes (two different-format
exports within one HTTP round-trip could persist out of order; a mutation
queue for a preference value is overkill). Both races are rare, bounded,
and self-correcting on the next export.

* Trim the comments and the old-behavior test guards

Review feedback: the comment on persistExportFormat ran longer than the
function and said what the code already says, and both the e2e comment
and the no-request-on-switch assertions pinned behavior we removed
rather than behavior we built.

Also makes the optional chaining on formatPreference consistent within
persistExportFormat.
2026-08-05 11:48:41 +00:00
Phoomparin Mano
7b02363e37 useCurrentUser hook returns null in data apps (#79445)
load current user on data apps
2026-08-05 18:30:21 +07:00
Fraser
f6e0e1653e Reduce viz dependencies on static-viz (#79426) 2026-08-05 20:52:17 +12:00
Andrei Simionescu
062c0f7e85 Update browserslist data (caniuse-lite) (#79369)
Refresh the bundled browserslist data to clear the "caniuse-lite is
outdated" warning on dev builds; the data was several months stale.
Lockfile only: bumps caniuse-lite to 1.0.30001806 and
baseline-browser-mapping to 2.11.9, and drops stale nested duplicates so
they resolve to the hoisted versions. Hand-edited because
update-browserslist-db misbehaves with bun in this repo.
2026-08-05 09:40:36 +01:00
Chris Truter
42afd6b9c1 Manifest-based plugin framework (#78308)
* Support manifest-based runtime plugins

Summary

- Extract shared dependency pruning for runtime plugin uberjars.
- Initialize non-driver manifest plugins eagerly behind plugin API version 1.
- Preserve lazy loading for legacy driver manifests, while treating an empty driver list as a non-driver plugin.

Scope

This is capability-neutral infrastructure. It does not define an embedding API or ship an embedder.

Testing

- Plugin uberjar pruning tests.
- Plugin initialization, version-gate, empty-driver, and lazy-driver tests.

* Make runtime plugins lazy by default

* Document lazy plugin loader constraints

* Name plugin dependency checks after registration, not init

A satisfied `plugin:` dependency means the other manifest is registered, not that its
code is loaded. Rename the argument threaded through the dependency checks to say so, and
record where load ordering actually comes from for the two driver manifests that declare
one.

* Make a failed lazy-driver load retryable

Load the plugin before removing the placeholder `initialize!` method, so a load
that throws leaves the driver retryable instead of stranded on the no-op default.
Guard the removal so a driver that installs its own `initialize!` during load
keeps it.

Also clarify the `register-plugin!` contract (manifest plugins defer the classpath
add and code load; bare dependency JARs go on the classpath immediately), rename
`prune-provided-libs` to `prune-provided-libs!`, and mark its pure test parallel.

* Drop ^:parallel from prune-provided-libs-test

prune-provided-libs! logs via u/announce, so kondo's :metabase/validate-deftest
rejects the destructive fn inside a parallel test. Keep the ! name, drop the tag.

* Generalize bundled plugin loading to non-drivers; review nits

Rename bundled-driver-manifest-paths / load-bundled-driver-plugin-manifests! to
drop the driver-only framing -- the classpath glob already discovers any bundled
manifest -- and document that bundled plugins carry no add-to-classpath!, so their
code never comes from the writable plugins directory. Add a test asserting a
bundled plugin activates by running init steps only.

Also delay the plugin-uberjar core basis so requiring the namespace no longer pays
for a full dependency-tree resolution, and drop the unused init-plugin-with-info!
compatibility alias.

* Use with-dynamic-fn-redefs in the plugin init tests

Every binding here redefs a plain defn, so prefer the thread-safe dynamic redefs
(clears :metabase/prefer-with-dynamic-fn-redefs) and align each binding block's
values consistently.

* Register bundled manifests before plugins-directory manifests

Registration is first-wins, so a JAR dropped in the writable plugins directory
could shadow a bundled plugin's name and run its own code under that identity.
Load bare dependency JARs first (for class: deps), then bundled manifests, then
user manifests -- so bundled, root-owned code always claims the name.

* Reserve bundled plugin names so plugins-dir JARs cannot shadow them

A bundled plugin with an unmet dependency (Oracle/Vertica without their JDBC JAR)
never registers, leaving its name unclaimed -- a plugins-directory JAR could then
register under it and run as the bundled plugin. Reserve every bundled name up
front and refuse a colliding plugins-directory manifest.

* Reserve bundled names against the dependency batch too

Classification and registration read the writable JAR separately, so a JAR that
looks like a bare dependency but resolves to a bundled-name manifest could
register under it. Compute the reserved set once and pass it to both batches.

* Fail fast on concurrent plugin loading

* Align plugin framework forms

* Guard plugin registration and loading with one lock

Registration synchronized on the registered-plugins atom while loading claimed a second atom by compare-and-set, so
two different locks guarded two views of the same state.
Both paths now take one ReentrantLock: registration waits for it, and loading still fails fast when another thread
holds it.

Because that lock is reentrant, a plugin whose init step loads another plugin no longer trips the concurrency check
on its own thread -- nesting is not concurrency.

The registered and loaded name sets collapse into a single registry atom keyed by plugin name, which leaves
active-plugin-load as a diagnostic that names the loader in the rejection message.

* Reject plugin load cycles

A reentrant lock let a plugin load itself, directly or through a cycle like A -> B -> A.
`:loaded?` is only set once the init steps finish, so each pass through the cycle ran them again until the stack blew.

The lock now carries a stack of the loads in progress: nesting distinct plugins is still fine, re-entering one that is
already loading throws and names the cycle.
A rejected concurrent load no longer reports that plugin `nil` holds the lock when the holder is registering rather
than loading.

* Tidy the plugin cycle tests

Hoist the shared registration out of the first `testing` block, drop the closure that wrapped it, and share one uuid
between the two plugin names.

* Drop the temporary atom in update-unsatisfied-deps!

Everything here runs under plugin-lock, so a read and a reset does the job.
2026-08-05 09:57:14 +02:00
Romeo Van Snick
cf7bf4dd24 Converge LocationDescriptor onto react-router's To (#79338)
* Repoint navigation targets onto react-router's To

* Repoint location readers onto react-router's Path

* Pass the query builder's card state through navigate options

* Delete LocationDescriptor and toNavigateArgs
2026-08-05 14:16:23 +10:00
Anderson
fa991d5496 Allow drag-and-drop into sub-collections shown in the collection items table (#78839)
* Allow dropping items into sub-collections shown in the collection items table

* Trim comments in collection drop target

* Accept documents and metrics in collection drag-and-drop targets

* Improve collection drag-and-drop feedback

* code clean up

* Remove redundant React keys from drag source table row
2026-08-05 03:57:52 +00:00
Romeo Van Snick
55026d5ff5 Delete the v3 history handle from the test harness (#79297)
* Drive router specs through a v7-shaped test router handle

* Delete the v3 History type from the router facade
2026-08-05 11:57:21 +10:00
Romeo Van Snick
8d14aa8391 Retire redux navigation in favour of useNavigate (#79300)
* Retire redux navigation in favour of useNavigate

`push`/`replace`/`goBack` and the `CALL_HISTORY_METHOD` transport are gone.
Components navigate with `useNavigate`. Code that cannot call a hook (thunks,
the session middleware, a reducer, the permission and visualization config
objects) uses a module-level `navigate`, which is the navigator holder `AppShell`
already filled in, exported directly.

`metabase/router` exports its own `useNavigate`, which falls back to that
`navigate` when no router is mounted rather than throwing, because redux
navigation had no opinion on whether one was. The SDK mounts none, which is what
`useMaybeLocation` already exists for.

Four things this could not do mechanically:

* Relative targets resolved from the root under redux navigation, because
  `AppShell` sits on a pathless layout route. `useNavigate` resolves against the
  component's own match, so `SearchBar`, `SearchApp` and the command palette
  needed absolute paths.
* A `LocationDescriptorObject` is assignable to v7's `To`, so passing one to
  `navigate` type-checks and drops `state` silently. Six sites carried state,
  including the QB card-state contract.
* `mapDispatchToProps: { onChangeLocation: push }` cannot hold `navigate`, since
  `connect` dispatches what a bound creator returns. Five containers became
  function components, the dashboard context supplies it in the provider, and
  three `reference/` pages had it wired but never read it.
* A permission `actionCreator` returned an action to dispatch. It is now
  `onSelect`, returning void, and `updateDataPermission` treats the presence of a
  post action as handled rather than inferring it from a truthy return.

LOCATION_CHANGE and the location mirror stay: they are the router-to-redux
direction, feeding `isNavbarOpen`, `errorPage` and trace-id rotation.

* Hold navigations only when a router is on its way

A host that builds no router never registers a `navigate` for the holder to
flush against, so anything held is held for the life of the page. The SDK is
such a host and runs for a long time, so every `navigate` call from it retained
a closure and the `state` that closure captured.

`createAppRouter` and `createMemoryAppRouter` now say that a registration is
coming, because both put an `AppShell` above the tree. Without that, a
navigation with nowhere to go is dropped rather than queued, so a router-less
host retains nothing.

The queue stays a queue. Collapsing it to the latest navigation would change the
history stack: a push followed by a replace would apply the replace to the entry
the user arrived on, and lose the intermediate one.
2026-08-05 10:08:55 +10:00
Eric Normand
43915d7353 GHY-4251: Bound dependency backfill memory and stop its crash loop (#79392)
* GHY-4251: Bound dependency backfill memory and honour its off-switch

The BackfillDependencies job OOM-killed instances with large warehouses. Native-query dependency
calculation resolved parsed table names by fetching the Database's entire table catalog and scanning
it in Clojure, so cost scaled with warehouse size rather than query size. Reading an entity also
attaches a MetadataProvider to its query, and the job bound no provider cache, so each entity in a
batch got a private provider holding its own copy of that catalog -- all of it retained for the
lifetime of the batch. At batch size 500 against a ~20k-table warehouse that is ~10M retained table
rows, which no amount of heap fixes.

Three changes:

- Look up only the tables a query names. New :name-ci metadata spec matches names
  case-insensitively, since normalize-name lower-cases while sync stores whatever the warehouse
  reported (Snowflake reports upper-case). Unlike :id/:name it does not widen the result set to
  inactive or hidden objects, so it narrows an otherwise-unfiltered fetch without changing which
  tables are eligible. The SQL narrows and find-table-or-transform still decides, so matching
  semantics are unchanged. Transforms keep the existing scan -- their target name lives in a JSON
  column with nothing to query -- which is fine at their cardinality.

- Scope a MetadataProvider cache to the backfill batch, spanning the select as well as the
  processing loop, so entities share one provider per database instead of one each. This is the
  invariant deps.findings/upsert-analysis! already enforces by throwing.

- Honour a non-positive batch size in schedule-run! for both dependency jobs. It is the supported
  off-switch, but only task/init! checked it; the event-driven trigger did not, so entity changes
  kept waking the jobs every second to do nothing. schedule-run! is the one function all three
  scheduling paths share. The setting is env-var-only, so it cannot change while the process runs
  and the job stays unscheduled until restart.

* GHY-4251: Record the failure when a fatal error kills a backfill entity

backfill-entity-batch! caught Exception, so an Error -- an OutOfMemoryError in the reported case --
escaped without recording anything. The next run then selected the identical batch and died the same
way, indefinitely, with no progress and nothing in dependency_status to show for it.

Catch Throwable instead and record the failure before letting an Error propagate, so a poisonous
entity backs off and eventually goes terminal and the batch can drain. Errors are still rethrown
rather than swallowed: the rest of the batch is abandoned and the job fails, because the JVM is in a
state we cannot reason about. Exceptions keep their existing per-entity behaviour.

* GHY-4251: Only let pending retries keep the backfill job scheduled when licensed

The job reschedules itself when a full batch was selected or when entities are in retry backoff. The
retry check ran outside the :dependencies feature gate that guards the work itself, so an instance
without the feature kept rescheduling hourly forever: a retry marker is only cleared by processing
the entity -- on success via upsert-status!, or by going terminal past max-retries -- and both are
gated on the feature, so the job could never resolve the condition keeping it awake. It took the
"No more entities to backfill for, stopping" branch never.

Gate the check on the feature. Requires the feature to be enabled, and gives up an accidental warm
standby: with the feature off and retries pending the job previously stayed scheduled and would pick
up work the moment the feature flipped. Recovery does not depend on that -- setting the token
publishes :event/set-premium-embedding-token, and every content-change handler triggers the job -- so
the remaining exposure is an upstream-only change to a token's feature set, which starts the backfill
on the next content change rather than immediately.

* GHY-4251: Correct the off-switch rationale in schedule-run! docstrings

Both docstrings claimed the batch size setting is env-var-only. It is not: get-raw-value consults the
application database after the env var, and :setter :none only blocks writes through the settings API,
so a stored value would be honoured if one existed. The conclusion is unchanged -- there is no
supported way to change it at runtime, so a cold stop until restart is right -- but the stated reason
was wrong, and it is the reason a future reader would rely on.

* GHY-4251: Stop the entity check job rescheduling when it cannot do anything

DependencyEntityCheck is a periodic checker with no terminal state, so it always queues another run.
That is correct while licensed, but check-entities! is gated on the :dependencies feature while the
reschedule was not, so an instance without the feature woke every 30-90 minutes forever to do nothing.
Same defect as the backfill job's retry check, reached by a different route.

Extract the reschedule out of the Quartz job body -- it only needed the scheduler, not the whole
execution context, which also makes it testable -- and gate it on the feature. If the feature is
enabled later the job restarts from the content-change event handlers, or from task/init! on the next
restart.

* Update docstrings

* GHY-4251: Record a failure for entities that have no dependency_status row yet

record-failure! only updated an existing row, so it silently did nothing for an entity that had never
been processed. Those are exactly the entities instances-for-dependency-calculation picks up first --
it matches a null status row -- so on a first backfill every entity is in that state. A failure
recorded nothing, the entity was reselected unchanged on the next run, and the crash loop the previous
commit set out to break stayed intact for the population most likely to cause it.

Upsert instead, via app-db/update-or-insert! like the other writers in this namespace. An inserted row
takes the column defaults for stale (false) and dependency_analysis_version (0); 0 is below
current-dependency-analysis-version, which is what keeps the entity eligible for the retry once
next_retry_at elapses.

This also fixes the "failure count: null" log line in the backfill catch path, which read back a row
that did not exist.

Found in review by escherize.

* GHY-4251: Make the dependency jobs' reschedule decisions licence-failure tolerant

Two problems with gating the reschedule on has-feature?, both introduced by the previous two commits.

The entity check job had no event wiring at all, while the backfill job derives
:event/set-premium-embedding-token. Stopping its periodic chain when unlicensed therefore left no way
back except a content change or a restart, because the content-change handlers that would revive it are
themselves gated on the feature. Enabling the feature on an idle instance left the checker dead. Mirror
the backfill job's wiring so setting a token restarts it.

has-feature? also collapses "no licence" and "could not check the licence" into the same false:
*token-features* returns #{} on any Throwable, so a network timeout or tripped circuit breaker reads as
unlicensed. Where that decides whether to schedule it is terminal, since nothing re-fires when the check
later succeeds -- the token never changed. Both inputs to the backfill job's decision consult the
licence, so a single blip stopped that job too.

Resolve the licence for scheduling with canonically-has-feature?, which reports nil when it could not
tell, and stop only on a definitive false. No token at all still answers false, so the common unlicensed
case still goes cold; a token that cannot be validated keeps the job alive. Doing the licensed work
still uses has-feature? and still fails closed -- fail closed on work, fail open on scheduling.

Tests for both jobs now drive all three licence states. The previous two-state tests expressed
"unlicensed" with mt/with-premium-features, which only rebinds *token-features* and so cannot produce a
definitive false; they were passing for the wrong reason.

Found in review by escherize and metamben.

* GHY-4251: Select the analysis batch inside its metadata-provider cache

analyze-batch! selected its instances before entering with-metadata-provider-cache, so each entity in
the batch got a private MetadataProvider attached to its query -- the same shape as the backfill bug,
same default batch size, other job.

Unlike the backfill job this is not a live leak: upsert-analysis! ignores the embedded provider, builds
its own from the entity's database id inside the cache, and passes it explicitly to check-entity, so
those providers stay empty. It is a trap rather than a bug -- the moment an entity is analyzed through
its own query, a batch of 500 retains 500 providers' worth of fetched metadata. upsert-analysis! already
throws when no cache is bound, so the cache was always meant to cover this work; only the select sat
outside it.

Moving the select inside the cache is behaviour-neutral today. The new test pins the intent by comparing
provider identity, since providers compare equal when they wrap the same database id.

Found in review by escherize.

* GHY-4251: Back the dependency test fixture's Tables with the application database

The shared fixture composed its Cards and snippets over metabase.lib.test-metadata, so its Tables existed
only in memory. That is fine for metadata the provider legitimately supplies, but resolving a table name
parsed out of native SQL is a lookup against the appdb, and an in-memory base has no rows to find.

Swap the base to the appdb-backed provider. The H2 test-data database already has the tables these tests
reference, so nothing needs creating; expectations move from mock ids to the real ones. Cards and snippets
stay mocked, since nothing looks those up by name.

These pass both before and after the implementation change in the next commit, which is the point: they
verify behaviour rather than a particular lookup mechanism.

* GHY-4251: Resolve table names with a t2 query instead of a MetadataProvider spec

Narrowing the catalog fetch was implemented as a new :name-ci MetadataProvider spec key. That put the
contract on every implementation for the benefit of one caller, and they did not agree: the overriding
provider ignored the key and fell into its fetch-everything branch, the composed provider took a
fetch-then-refetch path, and the cached provider keyed it by whole-spec so batch sharing was poor.

Resolving a table name parsed out of native SQL is a lookup against the application database, not query
metadata, so do it directly: lib-be/tables-by-name, case-insensitive because normalize-name lower-cases
while some warehouses report upper-case. The SQL still only narrows -- find-table-or-transform continues
to decide -- so matching semantics are unchanged.

Two consequences worth naming. sql-tools now uses lib-be, where the provider previously hid the appdb from
it. And a native query naming a transform's not-yet-created output table now resolves to {:transform id}
via the transform branch rather than {:table <invented-id>} from an override; a dangling invented table id
was the worse answer, but it is a change.

Verified with the OOM repro: 0 catalog fetches, completes in a 1200m heap where master OOMs. Per-card appdb
query volume is unchanged from the spec-key version.

Suggested in review by Alex.

* GHY-4251: Reschedule after a failed run, and keep a disabled job warm

Two changes to when the dependency jobs schedule themselves.

A failure no longer ends the chain. Both jobs are one-shot self-rescheduling with no cron backstop, so an
Error escaping the batch skipped scheduling entirely and the job stayed dead until a content-change event
or a restart. That is survivable for an OutOfMemoryError, which usually takes the process with it and lets
task/init! recover on boot, but not for a StackOverflowError from pathological SQL, which leaves a live
process behind. The backfill job body is extracted to run-and-reschedule! -- it only ever needed the
scheduler, not the whole execution context, which also makes it testable -- and reschedules on the way out
of a failure before rethrowing.

A non-positive batch size now suppresses only the event-driven trigger rather than all scheduling. The
1-second trigger fires on every entity change and never consulted the batch size, which is what made a
disabled job wake roughly once a second; that is the part worth stopping. The job's own periodic schedule
is left alone, so a disabled job keeps ticking slowly and resumes if the setting ever becomes positive
instead of staying cold until a restart. task/init! schedules regardless and logs that nothing will be
processed.

Note the resulting asymmetry is deliberate: scheduling now fails open on an indeterminate licence but still
stops on a non-positive batch size, because the batch size is definitively knowable and a token's status is
not.

Raised in review by metamben; Error case from escherize.

* GHY-4251: Cover the case folding tables-by-name depends on

The narrowed table fetch is a prefilter, not the decision: it selects with SQL lower(name) against names
lowered by u/lower-case-en, then find-table-or-transform matches precisely using the driver's
normalize-name. That is only correct while the prefilter returns a superset of what the precise match would
accept, and one of the two assumptions behind that is testable -- the appdb's lower() has to agree with
Java's.

Add a test that stores tables under ASCII and accented-Latin names and looks each one up by its own,
lower-cased and upper-cased spellings. It needs no per-driver harness: the backend workflow runs it on H2
and app-db.yml runs it on Postgres and MariaDB, so a collation that folds differently fails it there.

Deliberately out of scope, and noted as such: locale-dependent folds like U+0130, where lower() legitimately
differs between engines. The other assumption -- that a driver's normalize-unquoted-name is a pure case
fold -- cannot be tested from here, so it is recorded as a comment at the call site.

* GHY-4251: Cover the other assumption behind the narrowed table fetch

tables-by-name is a prefilter and find-table-or-transform makes the decision, so the narrowing is only sound
while the prefilter returns a superset of what the precise match accepts. The previous commit tested one of
the two assumptions behind that and claimed the other could not be tested from here. It can: "a driver's
normalize-unquoted-name is a pure case fold" is just the assertion that lower-casing the input and the
normalized output gives the same string. Trimming, stripping or substituting characters all break it, and
any of them would make the prefilter drop rows that should have matched.

Each implementation is invoked directly rather than through driver dispatch, so no driver needs registering
or initialising. The test also asserts which implementations it found, because `methods` only sees those
loaded in the run -- without that it would quietly keep passing while covering less and less.

Comment at the call site updated: both assumptions now name their test rather than one being described as
untestable.

* GHY-4251: Avoid the deprecated mbql-query in the new findings test

The full kondo lint that CI runs reports cross-namespace deprecation warnings that per-file runs do not,
and CI fails on warnings. The new provider-sharing test used mt/mbql-query, deprecated since 0.61.0 for
returning legacy MBQL 4.

The test only needs two cards with distinct queries, so use native ones, matching the equivalent test in
backfill_test. That avoids the deprecation rather than adding a namespace-level exclusion for it.

* GHY-4251: Resolve table ids with t2 in sql-tools, then fetch metadata through the provider

Replaces the lib-be/tables-by-name added in 88b011c527. Resolving a name parsed out of native SQL is a
lookup, so sql-tools does that itself against the appdb and gets back ids only; the metadata for those ids
then comes through the MetadataProvider via lib.metadata/bulk-metadata, which is the ordinary :id spec.

Better than fetching the metadata directly on three counts. lib-be grows no public API. Overrides and
caching still apply to the metadata, because it goes through the provider. And the :id spec is cached
per-id, so entities in a batch naming overlapping tables share those entries -- the whole-spec keying of the
earlier version meant {orders} and {orders, products} were separate cache entries fetching orders twice.

The active and visibility filter moves into the id query: an :id lookup does not apply it, and the catalog
fetch this replaced was active-only, so it has to happen where the ids are chosen. The case-folding test
moves with the function, and gains coverage of that filter.

sql-tools no longer depends on lib-be. The module config records :model-imports #{:model/Table} instead,
which scopes the new dependency to the one model rather than a whole module.

Verified with the OOM repro: still 0 catalog fetches, completes in a 1200m heap where master OOMs.

Review feedback from Alex.

* GHY-4251: Drop an incidental reformat from lib_be/metadata/jvm.clj

Left over from the :name-ci spec key that was added and then removed. Restoring the line takes lib_be out
of the diff entirely, so this PR no longer touches any Querying Platform owned path.
2026-08-04 18:46:34 -05:00
Fraser
b633cdd03f Migrate small selector files to their domains (#79352) 2026-08-05 10:47:31 +12:00
Timofey Kachalov
821ed2e021 Adjust data apps skill to handle apps with height less than a viewport height (#79185) 2026-08-05 01:15:42 +04:00
Nemanja Glumac
e3ece7bdb4 Add granular reruns for backend tests (#78360)
* Add backend granular-rerun collector

Scan hawk's JUnit output and emit the failed test vars as a hawk `:only`
vector so a failed backend/driver run can be rerun targeting only those
tests instead of the whole suite. Reuses the existing backend JUnit
adapter. Writes nothing unless every failure is attributable to a var, so
an unattributable failure falls back to a full rerun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Wire granular reruns into the driver test action

On a re-run of a failed backend/driver job, restore the tests that failed
on the prior attempt and run only those via hawk `:only`, instead of the
whole suite. After a failed run, collect the failed vars and upload them
as an artifact keyed by junit-name; a re-run downloads it and targets those
tests. Falls back to the full suite whenever no confident failed-test set
exists (first attempt, missing/empty artifact, or unattributable failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Exempt stress workflows from granular reruns

Add a `granular-rerun` input (default true) to the driver test action,
gating the restore/collect/upload steps on it. Stress workflows set it to
false: they intentionally run a fixed test set `:times N` to hunt
flakiness, and narrowing a re-run to only the failed vars would drop that
repetition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Wire granular reruns into the non-driver backend tests

Extend the be-tests job with the same restore/collect/upload flow as the
driver action: on a re-run of a failed job, run only the tests that failed
on the prior attempt via hawk `:only`. The rerun drops the tag and
partition args, since the restored vars are already this partition's own
failures — re-partitioning the small set would drop half of them. Each
partition keeps a distinct artifact, so it reruns exactly its own failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Give app-db partition legs a per-job rerun artifact key

The granular-rerun failed-tests artifact was named solely from junit-name.
app-db's partition legs (Enterprise Tests, EE App DB Part 1/2) share one
junit-name, so they collided on a single artifact and a rerun could restore
the wrong leg's failed set. Add an optional rerun-artifact-key input to the
test-driver action and set a per-job-unique value for those legs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Collect hawk's var-less-errors JUnit file for granular reruns

hawk (PR #50) now writes fixture-init and namespace load/compile errors to a
fixed-name mb_hawk_var_less_errors.xml, with a testcase that has a name but no
classname. The backend collector only scanned *_test.xml, so it silently
ignored that file and the run's failing set stayed incomplete. Match the file
too; its classname-less testcase parses to a namespace-less failure, which
correctly forces a full rerun instead of a misleadingly narrow one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add a granular-rerun knob

* Scope the rerun artifact to the attempt that recorded it

The failed-tests artifact was named per job, so it outlived the attempt
that wrote it. An attempt that passed, or that failed with nothing
attributable to a test var, uploads no artifact, leaving the previous
one in place for the next attempt to restore -- narrowing a run that
should have gone wide.

Name it `...-attempt-N` on upload and read `attempt - 1` on restore, so
only what the immediately preceding attempt recorded is ever used and
anything else falls back to the full suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Compute the previous attempt number in the shell

Workflow expressions have no arithmetic operators, so
`${{ github.run_attempt - 1 }}` is not a valid expression -- actionlint
rejects it, and the runner would not evaluate it either.

Resolve it with `$((GITHUB_RUN_ATTEMPT - 1))` in a preceding step and
read it back from the env context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Clear pre-existing actionlint findings in the stress workflows

These workflows are only linted when a PR touches them, so the findings
below sat unreported until this branch edited both files.

`transforms-python-stress-test.yml` omitted the required
`previous-step-outcome` input on all eight `upload-test-results` steps;
each `test-driver` step already carries `id: test-driver`, so wire its
`test-outcome` output through.

`drivers-stress-test.yml` tripped shellcheck: an unquoted `$JAVA_HOME`
(SC2086) and three consecutive appends to an unquoted `$GITHUB_OUTPUT`
(SC2086, SC2129), now a single quoted group redirect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rename logs-artifact-suffix to artifact-suffix and reuse it for reruns

The failed-tests artifact needs a per-leg discriminator, which test-driver
cannot derive on its own: `matrix` and `strategy` are unavailable inside a
composite action, and `github.job` is the job id, identical across legs.
`logs-artifact-suffix` already carried exactly that value, so reuse it rather
than take a second input for the same concept.

Name both artifacts from `github.job` + `junit-name` + `artifact-suffix`.
Neither of the first two is unique on its own once run-tests.yml pulls app-db,
backend, drivers and semantic-search into one run and one artifact namespace:
`be-tests-postgres` is a job id in two of them and both derive
`be-tests-postgres-ee`.

Fill in the suffix wherever a matrix runs concurrent legs but did not set one,
or set one that did not separate them. Those legs already collided on the test
logs artifact; without this they would also restore each other's failed-test
set on a rerun and re-run the wrong tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Correct the var-less error description in the rerun collector

hawk writes var-less errors to mb_hawk_var_less_errors.xml without a
`classname`; the header claimed they leave no JUnit trace at all. What makes
them unusable for a narrow rerun is the missing namespace, not a missing file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let a colliding failed-tests upload fail instead of overwriting

The artifact name is scoped to the job and the attempt, so nothing legitimately
uploads it twice. With overwrite, a name collision would silently resolve to
last-writer-wins and a rerun would restore another job's failed set; without it
the upload 409s and says so. The step stays continue-on-error, so a collision
degrades to a full rerun rather than failing the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Clear the restored failed-tests file when a rerun can't be narrowed

The collector signals "rerun everything" by leaving no file, but on a rerun that
path already holds the previous attempt's set, restored for the test step to
read. Declining to write left it in place, so the upload shipped last attempt's
list as this one's and the next attempt narrowed to it again — a namespace-load
failure could keep the full suite from ever running again.

Delete it on the no-confident-set path, and again in the CI step before the
collector runs, which also covers the collector dying before it decides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 23:09:44 +02:00
Fraser
96dd9ac122 Move Metabot editor extensions out of rich_text_editing (#79295)
* Move metabase:// protocol link helpers from metabot to urls

The metabase:// URL protocol helpers (parse/create functions, regex
constants, and entity model types) are a cross-cutting URL scheme used
by both metabot and rich_text_editing, so they belong in metabase/urls
rather than metabot/utils. Moved links.ts (and its spec) to
urls/metabase-protocol.ts, exported it from the urls index barrel, and
updated all importers.
2026-08-05 09:08:17 +12:00
Fraser
294ccb87c6 Move metabase:// protocol link helpers from metabot to urls (#79117) 2026-08-05 09:02:26 +12:00
technomancy
aa65891204 Edit partition migration to only drop identity if it exists. (#79415)
For unknown reasons, the DB on stats does not have the id column as an
identity, but this is causing migration to fail.
2026-08-04 14:01:41 -07:00
Fraser
fead86be0a Create settings module (#78981) 2026-08-05 08:59:19 +12:00
Egor Grushin
ece1b7cefb Metrics explorer editor race fix (fixes a bunch of flaky tests) (#79128)
* fix race condition

* fix race condition

* prettifying

* drill-through beforeEach: wait @dataset after each run, brush raced second-series re-render
2026-08-04 20:52:45 +01:00
github-automation-metabase
c461aa579e Auto-update documentation for master (#79402)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-04 19:33:57 +00:00
Phoomparin Mano
68bfbd63f4 Move the date picker CSS instructions to main skill file (#79378)
move the datepicker instructions up to skill.md
2026-08-05 02:03:08 +07:00
Oleksandr Yakushev
5482bdf418 [test] Cache repeated values in metabase.sync.sync-metadata-test (#79276) 2026-08-04 20:15:28 +03:00
Oleksandr Yakushev
3652ff05f6 [test] Shorten quartz test backoff delay (#79275) 2026-08-04 20:15:15 +03:00
Michael Eby
c29f00d480 docs: correct Cloud query timeout to 20 minutes (#79390) 2026-08-04 12:59:17 -04:00
Alexander Polyankin
4eddba236d Enter the metric-expansion admin context once, not once per metric (#79064) 2026-08-04 11:32:03 -04:00
Jeff Bruemmer
78abe86574 docs - analytics events generation (#79377)
* fix regex

* cleanup

* edits
2026-08-04 15:28:50 +00:00
Anderson
56eec114ae Increase horizontal spacing between inline chart setting label and toggle / Convert ChartSettingsWidget.styled to css module (#78922)
Add horizontal spacing between inline chart setting labels and their widgets (convert emotion file to css module)
2026-08-04 11:56:19 -03:00
Riley Thompson
1103c1d219 Move sql driver to mbql5 compilation, remove sql-mbql5 driver (#77529)
* mysql mbql5

* clickhouse mbql5

* starburst mbql5

* sqlite mbql5

* presto-jdbc mbql5

* druid-jdbc mbql5

* vertica mbql5

* athena mbql5

* hive-like mbql5

* fix databricks

* fix path

* sqlserver mbql5

* oracle mbql5

* fix sqlserver tests

* fix test failures

* fix sqlserver test errors

* clean up

* kondo, test fix

* remove sql-mbql5

* remove from more drivers

* update empty string is null driver

* simplify helpers

* remove unneeded defmulti

* fix field clause

* sqlserver mbql5

* oracle mbql5

* fix sqlserver tests

* fix test failures

* fix sqlserver test errors

* clean up

* kondo, test fix

* small changes

* remove mbql-clause and mbql-clause-with-opts

* make fixes

* more fixes

* remove new mbql5 usages

* Require metabase.driver.sql.pivot so the [:sql :pivot] emitter registers

Removing the sql-mbql5 driver moved sql_mbql5/pivot.clj to sql/pivot.clj,
but the side-effect require moved nowhere: on master
metabase.driver.sql-mbql5 required its pivot namespace, and after the move
no production namespace loaded metabase.driver.sql.pivot at all. Without
that load the apply-top-level-clause [:sql :pivot] method never registers,
so native pivot queries compile to a plain GROUP BY with no pivot-grouping
column (breaking pivot results at runtime). Whole-suite CI masked this
because loading the pivot namespace's own test file registers the method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* lower ratchets

* fix ratchets whitespace

* use map over mapv

* remove oracle :value

* fix bigquery

* add back comments, update change log

* move comments

* remove unneeded mbql4 cases

* move pivot to sql

* destructure after clause check

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:44:13 -04:00
Stas Gavrylov
a7c0de1718 GDGT-2938: Unify spinner UI in Monitor grids (#79303)
* GDGT-2938: Review fixes

* Keep the broken-dependents column wide enough for its header

Capping the column truncated the 'Broken dependents' header, which turned on
the Ellipsified tooltip and left two copies of that text in the DOM once the
header was clicked. Its cells hold a short count, so auto width measures the
header and is already stable across pages.

* GDGT-2938: Address PR comments
2026-08-04 17:26:36 +03:00
Denis Berezin
0dc220840e UXW-4717 Display as dropdown options are cropped (#79319)
* UXW-4717 Display as dropdown options are cropped

* Remove comments
2026-08-04 17:22:41 +03:00
Chris Vandoodewaard
265aa21a24 cleanup unused files in frontend/test (#78232)
* remove unused mantineSelect

* cleanup unused files
2026-08-04 08:56:00 -04:00
Alexander Polyankin
6908f26b76 Fail loudly in dev and test when permission checks run one query per entity (#79244)
* Load permission caches whole, with tables in a RoaringBitmap

Each of the three request-scoped permission caches now loads every
database on its first miss, instead of loading the scope it was asked
about and tracking which scopes it covers. That removes the reason the
prime functions existed: a caller no longer has to declare what it is
about to check, so `prime-table-perms-cache`, `prime-db-perms-cache` and
`prime-schema-perms-cache` are gone along with their twenty-odd call
sites, and with them the whole class of bug where a new call path silently
becomes a query per table.

Loading everything is only affordable because the table cache stops
storing a value per table. It now keeps, per permission type, one
RoaringBitmap per permission value holding the IDs of the tables that
resolve to it — a partition, so a lookup walks the type's values and stops
at the one that matches, and each table ID is stored once. Table IDs are
unique across databases, so nothing is keyed by database any more, and
only tables with a grant of their own appear at all.

The bitmaps are built by mutation while private to the load and never
written to once published.

Uncached callers — a check on a user other than the requester, or inside
`disable-perms-cache` — still load just the one table or database they
ask about, since no later check will amortize a wider load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Only use a permission cache that a request scope actually bound

The three caches are dynamic vars, and `use-cache?` checked only that the
user being asked about was the requester -- not that anything had bound a
fresh atom. Code running outside `with-relevant-permissions-for-user` read
and wrote the JVM-wide root atom.

That was survivable while a cache tracked which databases and tables it
covered, since an unseen one still forced a load. Now that a load covers
everything, the first check for a user writes "all of it, for good" into
an atom with no lifetime, and every later check for that user in the same
JVM is answered from it -- so a permission granted afterwards is never
seen. Tests, which set `*current-user-id*` freely outside any request
scope, poison it on the first check.

The root binding is now nil and `use-cache?` takes the cache, so an
unbound one reads through to the database. This is what bounds the
staleness each cache's docstring promises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record which databases a cache load covered

A cache that loads everything cannot tell "the user has no rows for this
database" from "this database did not exist when I loaded", and answered
both as no-permissions. Callers fall back to the least permissive value,
so a database created inside a request scope came back `:blocked` --
which is what broke the driver and app-db suites: `mt/dataset` builds a
database inside the scope `mt/with-test-user` has already opened, and
every check against it was denied.

Each cache now stores, per user, the set of database IDs its load
covered alongside the index. A question about a database outside that
set reloads. The set is read before the load, so a database appearing in
between counts as uncovered and forces a reload rather than being
wrongly marked covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Revert the bitmap cache, back to scoped permission caches

Loading every database on a miss traded per-call scoping for a cache that
then has to prove what it covered, and it cost two correctness bugs of
the same family -- the cache over-claiming, first across time (no
lifetime) and then across databases (no coverage record). The memory win
did not pay for that surface.

Back to master's caches, which load the table or database they were asked
about and record it. The next commit adds what this branch was really
after: making an unprimed check loud instead of silently expensive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add strict cache-miss detection and prime the paths it found

Adds *perms-cache-misses-are-errors?*, bound true by
with-relevant-permissions-for-user in dev and test. A cache's first load
is ordinary; a later miss means an earlier load already covered a
different scope, which is the signature of an unprimed run.

prime-table-perms-cache now also primes the db cache and takes :db-ids,
since table-permission-for-user reads the database's own grant too, and
call sites pass :db-ids wherever they have them.

Genuine N+1s this found and fixed: readable-fields-only, metric
dimension filtering, and the schema listing's table filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Throw on the fifth cache miss, not the second

Measured across the search, sandbox, dashboard and sync suites: at a
limit of three, 30 tests fail; at five, 18; and raising it to eight or
twelve changes nothing. So the cases between three and five are bounded
-- a dashboard whose parameters span two databases, an entity read plus
a chain-filter elsewhere -- and everything still caught at five is an
unbounded run. MB_PERMS_CACHE_MISS_LIMIT overrides it for re-measuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the miss-limit env var, keep the measured constant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Correct the miss-limit measurement, revert the unfounded search prime

The 18 failures I measured against were pre-existing: the same batch on
the base commit produces them identically, so the apparent plateau from
five upward was those failures, not the limit catching anything.

Re-measured against that baseline: a limit of two flags 19 sites and
three flags 10, all bounded -- an entity read plus a chain-filter, a
dashboard spanning two databases -- and at four and above none flag. Five
stays, but as a backstop against runs that grow with the data rather than
a linter for every second check.

Also reverts the search prime, which was added on the assumption those
failures were permission misses. They were not, and it bought a query per
search for nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prime the db perms cache for search's per-table permission checks

`check-permissions-for-model` resolves `table` and `indexed-entity`
results against their database, so a search spanning N databases cost N
queries. Results are consumed as a stream, so the databases involved are
never known up front -- and a search ranges over all of them anyway, so
prime every database.

Caught by the strict cache-miss check: metabase.search.api-test's
permissions-test failed with 24 database-level misses, all from
search.impl/can-query?. Postgres-only because the appdb search engine is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Order broken pulses by id so notification order is deterministic

`broken-pulses` selected with no ORDER BY, so the order the notifications
went out in was whatever order the rows came back in.

handle-broken-subscriptions-due-to-bad-parameters-test asserts the second
email is the one about the slack pulse, so it fails whenever the two
broken pulses come back the other way round. Shard-dependent, since the
physical row order shifts as other tests churn the table -- it does not
reproduce standalone, and it fails on master too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Exclude destination databases from full-database selects

Destination databases are reachable only through their router and never
carry data_permissions rows of their own, so listing them just widens the
load. The permission graph in permissions_rest already filtered them;
admin-permission-graph and the new search prime did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prime table perms, not just db perms, for search's table results

A `table` search result is checked with `can-query?`, which goes through
table-permission-for-user -- and that reads the table's own grant as well
as its database's. The database half short-circuits only when the db-level
grant is already maximally permissive, which is why the CI failure showed
database misses alone; a user with table-level grants would have gone on
to miss the table cache once per result.

prime-table-perms-cache covers both caches. Passing only :db-ids keeps it
affordable: the table cache holds data_permissions rows carrying a
table_id, so the load is bounded by the user's table-level grants rather
than by how many tables exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Point the cache-miss error at one prime function and one entity

The message listed all three prime functions and the ex-data carried only
a kind, so it said a check was slow without saying which check.

cached-database-perms takes the table-id its caller was checking, when it
has one. A miss reached that way is a table check -- table-permission-for-user
reads the database cache on its way through -- so it now reports kind
:table and names prime-table-perms-cache, which primes both halves;
prime-database-perms-cache would have left the table half unprimed.

The kind -> function map is spelled out rather than built from the kind, so
renaming one of those functions turns it up. Also renames db to database in
the function names touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rename full-db-permission-for-user to full-database-permission-for-user

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Batch the db-ids table-perms load, and stop formatting ids as numbers

Two review findings, both real:

prime-table-perms-cache batched only its :table-ids branch. The :db-ids
branch passed the whole set to one statement, and search now hands it every
non-destination database, so a large enough instance would blow the
bound-parameter cap. Batched like its sibling and like
prime-database-perms-cache.

The miss message went through tru, so MessageFormat rendered table 1234567
as "table 1,234,567". It only ever throws in dev and test, so it does not
need translating -- plain format, no grouping. The test used id 42, which is
below the grouping threshold and so passed against the broken format; it now
uses ids past the thousands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:29:10 -04:00
Oleksandr Yakushev
521b4eb271 [test] Use pre-created H2 DB in metabase.query-processor.middleware.cache-backend.db-test (#79277) 2026-08-04 12:06:18 +03:00
Fraser
3550513bf9 Move DocumentMode into rich_text_editing (#79345)
Move DocumentMode next to its consumer in rich_text_editing
2026-08-04 19:14:48 +12:00
Fraser
630ccf8acc Fix boundary-stats spec fixtures for enforceSharedTiers (#79348) 2026-08-04 16:23:27 +12:00
Fraser
91cec8cad5 Add sub-tiers for shared modules (#79013) 2026-08-04 12:21:28 +12:00
Fraser
616ee101b7 Dev 2343 record coverage per test and track routes (#78195) 2026-08-04 12:07:22 +12:00
Jeff Bruemmer
e88986d08c docs - update lychee config (#79316)
* update lychee config

* bump lychee action
2026-08-03 15:41:11 -06:00
technomancy
f11098bf5c Add migration to fix query execution primary key name. (#79324)
The query_execution_partitioned migration failed on stats because the primary key
constraint had an inconsistent name; it originally came from h2 so it didn't match
the constraint name we saw in development. This migration should do nothing unless
the DB is in the same situation as stats where it has a bad constraint name.
2026-08-03 21:24:27 +00:00
Nemanja Glumac
0bbd9658b0 Assert absent connection-spec keys instead of using complement (#79330)
`=?` applies an expected function to the actual value, so `complement` --
`clojure.core/complement`, the higher-order function that negates a
predicate -- evaluates to `(complement <actual-value>)`, which returns a
new function. Functions are truthy, so those checks passed for every
input and asserted nothing. `:hawk/key-not-present` is the assertion the
lines were reaching for: the key is absent from the spec.

Hawk also stopped feeding absent keys into the expected predicate as a
truthy sentinel, and now reports a missing key as a diff outright, so
the remaining checks over keys that are not present no longer pass
vacuously either.

In `can-change-from-password-test` that exposed two assertions the test
DB could never satisfy: it authenticates with an uploaded private key
and sets `use-password false`, so `:password` was absent from both the
details and the resulting spec, while the first two cases assert that a
password is present and takes precedence over a key file. The details
now carry a password to switch away from, which is what the test is
about.

In `normalize-use-password-test`, `normalize-details` only ever assocs
`:use-password true`, and only when no private key is configured. The
DB under test has a private key id, so the key is never added -- absent
rather than false.
2026-08-03 21:12:41 +00:00
Andrei Simionescu
18673f52d4 Add andreis to Metabot team and slack map (#79323) 2026-08-03 21:41:30 +01:00
Oleksandr Yakushev
507db4b36f [test] Speed up metabase.util.queue-test (#79278) 2026-08-03 22:30:44 +03:00
Fraser
1c81a02962 Remove user slice (#77672) 2026-08-04 07:26:45 +12:00
metamben
9d12bffcd5 Fix test: send broken-pulse notifications in order (#79321)
The Postgres 14.x (Driver Tests) suite is broken because a test
expects the notifications to arrive in order.
2026-08-03 17:44:25 +00:00
Kamil Mielnik
245af1e945 Custom viz SDK 2.0 (#78354) 2026-08-03 17:31:54 +00:00
Chris Truter
acba9a1d24 Use scoped credentials for MCP UI (#79312)
* Validate OAuth token scopes for MCP

* Test MCP tool calls are refused outside token scopes

* Clean up scoped MCP token test

* Use scoped credentials for MCP UI

* Use MCP public API for UI credentials

* Fix credential test regex, auth-method arity test, reflection warnings

- api_test: the uiCredential extraction regex was over-escaped (matched
  literal backslashes) and could never match the rendered HTML, so
  mcp-ui-credential-validation-test failed on every backend job
- middleware session_test: auth-method grew a 5th arg (mcp-ui-info) but
  auth-method-test still called it with 4, throwing ArityException; also
  cover the new "mcp-ui" auth method
- session.clj: type-hint the signature comparison so Eastwood's
  reflection check passes
- align let/map bindings per style guide; mention the MCP UI credential
  path in wrap-current-user-info's docstring
- frontend: scope the no-literal-metabase-strings disable to the header
  line with a reason; drop now-unused setSessionTokenHeader and update
  stale comments that referenced it

---------

Co-authored-by: Bryan Maass <bryan.maass@gmail.com>
2026-08-03 11:14:09 -06:00
Chris Truter
bb7e944a22 Test OAuth token scopes for MCP (#79307)
* Validate OAuth token scopes for MCP

* Test MCP tool calls are refused outside token scopes

* Clean up scoped MCP token test
2026-08-03 17:10:48 +00:00
Eric Normand
b160246754 Keep the git sync cancellation error toast up for a minute (#79320)
The "Failed to cancel sync" toast used the app-wide 5s default, which is
too short to read and screenshot, especially since the same handler
dismisses the sync modal underneath it.

Fixes #79029
2026-08-03 16:40:54 +00:00
Anderson
b84036a312 Render pinned metrics preview charts consistently in collections (#79090)
* Fix pinned metric previews in collections

* Address review comments

* rename clj function, add comment
2026-08-03 13:28:22 -03:00
Stas Gavrylov
0eb9336cc6 BOT-1937: Restore clickable tool calls in Metabot debug chats (#79318)
BOT-1937: Restore the tool call details popup in Metabot chats

Moving the AI analytics views to Monitor replaced the self-contained tool
call modal with an onSelect callback, wired up only by the Monitor
conversation detail page. Normal Metabot chats pass no callback, so
clicking a tool call in a /debug chat did nothing.

Bring the modal back for callers that don't render details themselves,
composed from the existing ToolCallTitle and ToolCallDetailsContent so
the chat and the Monitor sidebar keep sharing one details renderer.
2026-08-03 18:28:17 +03:00
Nicolò Pretto
a747e0edb1 automatically enabled the custom viz used in entity in the embed wizard (#79183) 2026-08-03 14:55:31 +01:00
Anderson
ea64c1e6f6 Discard pending slice name edits when pressing Escape (#78690)
* Fix chart settings popover Escape cancellation

* Address review comments
2026-08-03 10:45:48 -03:00
Stas Gavrylov
a7691ffcbf GDGT-2931: Make Alerts Management tabs visible by default (#79166)
* GDGT-2931: Make Alerts Management tabs visible by default

Removes the full-page blocking loader on Alerts Management so tabs,
filters, and search render immediately, matching the pattern used by
Dependency Diagnostics and CLI/MCP analytics. The grid now shows an
animated skeleton while loading instead of blocking the whole page.

All three tabs (All/Failing/Ownerless) always render with a live count
badge instead of hiding tabs with zero results.

* Add positive assertion for the tab count loading skeleton

Give the skeleton badge a test id so the loading-state test can assert
it's actually rendered, not just that the count text is absent.

* Drop the visually-hidden loading status for the table skeleton

Reverting a restore I mistakenly applied earlier — this simplification
was an intentional manual edit, not something to undo.

* Remove the tab-switch refetch test and its listGate scaffolding

* GDGT-2931: Address PR comments
2026-08-03 16:18:53 +03:00
Kamil Mielnik
b8fe5f8247 GDGT-2875 Running a python transform without any table selected returns an unknown error (#79178) 2026-08-03 18:04:48 +07:00
Fraser
73e5cfb0f5 Move dashboard click-behaviour code into dashboard (#79145) 2026-08-03 22:09:27 +12:00
Romeo Van Snick
dd70db787e Import metabase/router through its single entry point (#79298) 2026-08-03 18:46:35 +10:00
Kamil Mielnik
adecad50c1 GDGT-1782 Timezone detail is missing when Setting up Subscriptions (#79171) 2026-08-03 14:41:24 +07:00
Romeo Van Snick
ffc964cd53 Move the app's link onto react-router's NavLink (#79296)
* Export react-router's link primitives from metabase/router

* Move the app's link onto NavLink and drop the v3 active-link props
2026-08-03 17:22:44 +10:00
Romeo Van Snick
8f68857a67 Drop the last v3 parity behaviours in metabase/router (#79294)
* Use react-router's own <Navigate>

* Interpolate redirect targets with generatePath and resolve them like v7

* Justify queryToSearch's encoding on its own terms, not history@3's
2026-08-03 16:35:11 +10:00
Fraser
27a7dc50e6 Enforce module public APIs (#79292) 2026-08-03 18:16:10 +12:00
Romeo Van Snick
452f689750 Author the app's routes as data routes (#79268)
* Author the app's routes as data routes

* Render route objects in specs through a renderRoutes helper
2026-08-03 15:27:52 +10:00
Romeo Van Snick
48a01bd3b0 Collapse the metabase/router facade onto react-router (#79242)
* Re-export react-router from the barrel instead of a file each

* Flatten the v7 folder into metabase/router

* Drop the V7 suffixes and merge the two router providers

* Use react-router's own hooks and types instead of mirroring them

* Drop the dead function form of a Link target
2026-08-03 14:30:01 +10:00
Fraser
b294a66b56 Record video on e2e re-run attempts (#79279) 2026-08-03 15:47:36 +12:00
Fraser
d9064c454a Use subscription-appropriate error fallback in pulse actions (#79113)
The subscription save path borrowed getActionErrorMessage from
metabase/actions/utils, whose fallback text ("Something went wrong
while executing the action") is wrong for this call site. Call
getResponseErrorMessage from metabase/utils/errors directly with a
subscription-specific fallback, and drop the actions import.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 15:47:08 +12:00
Romeo Van Snick
947c11579b Delete the Route translation layer (#79151) 2026-08-03 10:37:55 +10:00
dpsutton
6fcdf94fa5 Put CI MySQL/MariaDB datadirs on tmpfs (#79226)
* Put CI MySQL/MariaDB datadirs on tmpfs

A full app-db migration chain on a stock mysql:8.0 container is ~59s of
which ~44s is fsync waiting: MySQL 8 images default to full durability
with the binlog on (log_bin=1, sync_binlog=1, flush_log_at_trx_commit=1),
every DDL statement implicitly commits, and DDL additionally fsyncs the
table files it creates — one measured setup-db! run performed ~50k data
fsyncs + ~37k redo-log fsyncs across 1,464 changesets. No statement is
hot; it is a uniform ~30ms/changeset disk tax, paid for a database CI
deletes minutes later.

Durability knobs cannot recover it: the dynamic ones
(flush_log_at_trx_commit=2, sync_binlog=0) win back only ~20s because DDL
data-file fsyncs are not governed by them, and --skip-log-bin needs a
server command-line flag GHA service containers cannot pass. --tmpfs fits
the one field they do accept (options:) and neutralizes every fsync class:
measured 59.3s -> 15.8s per setup-db!, reproduced.

Applied to every mysql/mariadb service container (app-db, drivers,
semantic-search, cross-branch-migrations). Expected effects: the :db init
timeouts that outran #79014's 120s bump within a day become structurally
impossible (~5-16s init), the ~25min of Liquibase inside a mysql app-db
job mostly evaporates, and job duration stops tracking runner disk
contention. Cost: the app db lives in runner RAM (test DBs are MBs) and
images with pre-baked datadirs re-initialize at container start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCo9W7tgK13nNSfWTtQoUc

* Add required description to the cross-branch-migration-test action

Pre-existing actionlint error surfaced by touching
cross-branch-migrations.yml (actionlint scopes to changed files).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCo9W7tgK13nNSfWTtQoUc

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:27:25 -05:00
Oleksandr Yakushev
51bc6d11ac [test] Optimize download-exports-test/pivot-with-scale-test (#77066) 2026-08-01 13:59:13 +03:00
Oleksandr Yakushev
37fccf450c Bump Kondo version (#78647) 2026-08-01 11:33:41 +03:00
Arik Aronov
b7330b60a4 Resolve snippet template tags subject to read permissions (#78905)
* Resolve snippet template tags subject to read permissions

* snippet read permission test

* fix kondo module config
2026-08-01 11:29:53 +03:00
Romeo Van Snick
bd1d5d08ef Run the route-leave guards on native useBlocker (#79149)
* Run route-leave guards on native useBlocker

* Chain the route-leave guards and scope the registry to the provider

* Keep the prompted destination when another navigation supersedes it

* Keep the held navigation in the ref, not resynced from state

* Resume the captured navigation instead of reissuing it

* Revert the supersede handling, keeping the guard fan-out

* Stop the dashboard syncing its URL while a leave prompt is up
2026-08-01 09:54:22 +10:00
Alexander Polyankin
f94fc55418 Cut per-request permission work: consolidate caches, prime table checks, drop the visible-collection set (#79197) 2026-07-31 23:15:52 +00:00
Andrei Chernykh
563476b207 chore: Convert metabase-lib/v1 metadata utils to TypeScript (#79072)
* Convert metabase-lib/v1 metadata utils to TypeScript

* Address review: reuse shared template-tag mock, tighten schemaName type, restore id-null test case, hoist virtual-table id lookup
2026-07-31 15:57:31 -07:00
Nemanja Glumac
4b69a5a61d Remove Trunk (#79199) 2026-07-31 23:38:29 +02:00
Oleksandr Yakushev
5918cc9a6b [test] Optimize modules-test (#77065) 2026-07-31 23:44:41 +03:00
technomancy
c28f952435 Partition the query_execution table when using Postgres AppDB. (#78497)
* Partition the query_execution table when using Postgres AppDB.

The `query_execution` table gets very big on large installs and poses
performance problems. Partitioning it will allow us to get those under control.
This will only apply when using Postgres as the AppDB. If you have an H2 AppDB,
it's probably not big, and if you have a MySQL AppDD, then, uh, well, too bad.

The basic plan is to rename the existing `query_execution` to
`query_execution_old` and then create a new `query_execution` partitioned table
with the same schema. Once that's done, we can attach `query_execution_old` as
a partition to the main table, and all the data should be available transparently.

Unfortunately partitioned tables have gotchas that complicate things. This
means the migration plan is split into different steps.

The partitioned table must not have a primary key that doesn't include the
partition key. In order to do this, we have to stop using `id` as the primary
key and replace it with a composite key of `id, started_at`.

The first migration swaps the primary keys around, dropping the ones
based on the id alone and replacing them with ones based on `id` and `started_at`.

The second migration renames `query_execution` to
`query_execution_old` and then creates the new `query_execution` table
which is based on `query_execution_old` but partitioned by
`start_at`. Then it takes the `query_execution_old` table and attaches it as a
partition into the full `query_execution` table. This partition covers up to
the current month, plus one more after, in order to handle the case where the
migration runs at the end of the month and query executions need to be written
to disk before the job to create the next partition gets a chance to run.
The table's IDENTITY has to be dropped since this can't be done in a partition;
it should be handled by the higher level table.

The v_query_log view references the old query execution (now `query_execution_old`)
table. We need to re-create the view to point it at the new table instead.

Finally a migration dynamically calculates a new name for `query_execution_old`
to turn it into `query_execution_2026_08` or whatever, depending on
the date of the data that the table will actually contain.

* Add a metabase.app-db.partitions jobs for managing old/new partitions.

The migration puts us in a good state for the present, but as time
goes on, we'll need new partitions for new months. The new
`metabase.app-db.partitions` namespace handles this by defining a new
`ManagePartitions` job that runs daily to see if any new partitions
need to be created or whether any existing partitions need to be
detached.

Each run checks the current month's partition and the next month's
partition and creates them if they haven't been created yet. It also
checks for the retention cutoff period to see which partitions to
detach.

The individual logic functions and the `manage-partitions` function
that tie them together are tested, but the `defjob` is not; I don't
know if there is a way to test jobs.

There is no mechanism for actually deleting the old data once it
passes out of the retention period; it merely becomes detached. We may
need to document this somewhere so that users know they can clean it
up to save disk space. (It's unclear to me where that would be
documented.)

* Disable truncate-audit-tables on query_execution when using postgres.

* Address review suggestions.

* Set sequence on new partitioned table to match old sequence value
* Ensure partitioning job is loaded at startup
* Fix off-by-one error in partition boundaries for dropping
* Use locale-independent yyyy year formatting
* Move to metabase.audit-app.task.partitions

Still needs to address the fact that rollback only converts a single
table instead of all partitions back into one table.

* More review suggestions:

* Fix rollback sequence restoration to target old identity sequence.
* Fix rollback of table renames to use the oldest partition.
* Make partition management cron fire immediately in cases when it's been missed.
* No need to export retention days setting in audit-app module.

* Don't recreate primary key with id, started_at; just drop the primary key.

As far as I can tell, the primary key is not used any more since the
field_usage table got dropped; all reads of this table are just counting.

* Drop partitions instead of just detaching when cycled out of retention.

* Fix rollback to drop view first, then recreate at the end.

* Avoid relying on max(id) in partition migration.

The index will be gone by the time this runs, so it would result in a
full table scan of a HUUUUUUGE table.
2026-07-31 13:25:16 -07:00
Oleksandr Yakushev
2221279f6f [test] Don't attach unrolled query args to thread context (#77580) 2026-07-31 23:24:07 +03:00
Oleksandr Yakushev
3c1615f815 [malli.registry] Try look up schema in cache without de-regexping it (#78099) 2026-07-31 23:23:01 +03:00
metamben
c991b6d3f7 Compare ex-data, ex-message of throwables on pivot parity mismatch (#79180)
This is a follow-up to #78843.
2026-07-31 19:25:08 +00:00
Nemanja Glumac
6c519afd69 Wire the minimal-tests to the ci-conductor quarantine (#79160) 2026-07-31 20:47:40 +02:00
Ben Grabow
f85ec40f72 Use explicit hierarchy for events (#78971) 2026-07-31 13:31:37 -05:00
Jeff Bruemmer
789e16832c docs - embedding a chart (#78856)
* draft

* note

* image and links

* interactive

* intro table

* edits

* various edits and link fixes

* edit

* Optimized 1 images, saved 247.2 KB (43.7% smaller)

* Apply suggestions from code review

Co-authored-by: Alex Yarosh <alexandra@metabase.com>

* Apply suggestions from code review

Co-authored-by: Alex Yarosh <alexandra@metabase.com>

* query

* images

* edits

* intro

* question

* remove table

* intro

* edits

* edit

* Apply suggestions from code review

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>

* question reference

* revisions

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Alex Yarosh <alexandra@metabase.com>
2026-07-31 13:52:26 -04:00
Nemanja Glumac
4f4264a011 Bump hawk to 1.0.18 (#79070)
* Bump `hawk` to `1.0.18`

This corresponds to
c17e2b9b4e

* fix bad tests

* select data key

---------

Co-authored-by: Riley Thompson <rileythompson99@gmail.com>
2026-07-31 17:51:25 +00:00
Arik Aronov
8b8d31b5d2 card creator field selection (#79065) 2026-07-31 20:45:54 +03:00
metamben
e0d007399f Set explicit default binning when "Don't bin" is specified (#76930)
* Set explicit default binning when "Don't bin" is specified

Closes QUE2-91
2026-07-31 17:38:08 +00:00
Nemanja Glumac
71fd271cc6 Retry the quarantine list fetch before giving up (#78951)
* Retry the quarantine list fetch before giving up

A dropped socket or a 5xx from ci-conductor made the gate report COULD NOT
CHECK on the first try. Transport errors, timeouts, 429s and 5xx now get three
attempts with exponential backoff and jitter; a 4xx returns straight away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* improve retry api

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ryan Laurie <iethree@gmail.com>
2026-07-31 17:21:44 +00:00
Timofey Kachalov
45bf409390 Improve errors when data app iframe is loaded without a parent or when iframe can't be loaded (#79169) 2026-07-31 19:57:38 +04:00
github-automation-metabase
1d78177e7c chore(sdk): bump SDK version on master to 0.64.0-alpha.2 (#79174)
Co-authored-by: Timofey Kachalov <timofei@metabase.com>
2026-07-31 14:44:21 +00:00
metamben
335773bf84 Enable native pivot for easy native pivot candidates (#78843)
* Enable native pivot for native pivot candidates
* Add setting the optionally enable native pivot processing
2026-07-31 17:06:14 +03:00
Sébastien
5cd3310e0e Use the MCP icon for MCP analytics in Monitor nav (#79161) 2026-07-31 15:34:28 +02:00
Nicolò Pretto
987f30803a add support for custom viz in EAJS (#78133)
* Add allowedCustomVisualizations setting to EAJS embeds

This commit adds the plumbing necessary to pass down the
value of the allowlist for custom viz.
It warns when wrong names are passed (ie: not starting with `custom:`),
and forces [] for guest embed as the feature is not supported there.

* Move sandbox-host to its own namespace to remove +auth from it

The donor document is loaded as an iframe src, which cannot carry session
headers, so the endpoint must be reachable without authentication. Auth is
applied per-namespace by ns-handler in custom-viz-plugin.api and cannot be
lifted for a single endpoint, so the endpoint moves verbatim to its own
namespace, mounted without +auth next to the session-authed routes (both
still behind the :custom-viz premium gate). No behavior change other than
dropping the auth requirement.

* Serve the custom viz sandbox donor to EAJS embeds
Makes the donor iframe-able as long as either a valid session is passesed
or the correct sec-fetch-site headers are passed.

* Allow blob: images on the EAJS embed page for custom viz icons

Custom viz icons in the chart type picker are served by the SDK asset
manager as blob: URLs (a cross-origin img cannot carry the session
header, and EAJS has no cookies). The modular embedding page runs on the
Metabase origin under a strict img-src that omits blob:, so the browser
blocked the icons and left a broken image in the picker. Add blob: to
img-src only for the /embed/sdk/ entrypoint, gated on a URI predicate so
the main app and static or public embeds keep the stricter policy.
2026-07-31 15:21:05 +02:00
Mahatthana (Kelvin) Nomsawadi
2c8d1ad694 chore: tidy the SDK release dispatch forms and bump output (#78999)
* chore: tidy the SDK release dispatch forms and bump output

The dispatch form renders an input's description as the control's label,
which is a single plain text node: newlines collapse to spaces and no
markdown is parsed. Both SDK release workflows had multi-line
description blocks written as if they rendered, so release_type's
occupied ten rendered lines and pushed the select below the fold.

- release_type: one-line description keyed by option, options reordered
  into release-cycle order, and default: alpha pinned so the reorder
  does not move the preselection. Option values are unchanged.
- Detail that no longer fits the form moved to the file header.
- Shortened the two read_first option strings that overflowed the
  collapsed select at 44 and 81 characters.
- Gave the version-bump PR body's table a real header instead of an
  empty one (EMB-2167).
- Capture the new PR's URL and append it to the job summary; the
  existing summary step runs before the PR exists (EMB-2168).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo

* chore: capitalise release type names in the dispatch label

The label renders as plain text, so no markdown emphasis is available.
Caps makes each option findable when scanning the wrapped paragraph.
Values stay lowercase - this is display emphasis only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo

* chore: say what each dispatch input is for, not what it isn't

prerelease_id read as an output description, so it never said what to
type. Phrase it as a mapping: enter data-apps, get 0.62.5-data-apps.0.
Same length, same six rendered lines.

The release workflow's label opened with a double negative - use it
'when neither the bump PR nor re-running the failed jobs will do it' -
which left the reader to infer the purpose. It is a manual override, so
say that first. Retry guidance stays in the file header, where there is
room for it. Eight rendered lines down to six.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo

* chore: drop a redundant comment above the dispatch label

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:12:08 +01:00
Sébastien
2627e335f3 Record CLI usage analytics from the REST API middleware (#79152) 2026-07-31 14:37:43 +02:00
github-automation-metabase
d5a43a3a3a Auto-update documentation for master (#79102)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-31 07:59:56 -04:00
YB
1c4ea2115b [GDGT-2940] Upgrade libthrift to 0.24.0 (#79148)
Dependency upgrade: align both drivers that ship org.apache.thrift/libthrift on the latest release (0.24.0).

hive-like: bump the existing direct pin 0.16.0 → 0.24.0. Since 0.19.0, libthrift declares runtime deps (httpclient5, httpcore5, jakarta.servlet-api) that only serve its THttpClient/TServlet classes; hive-jdbc is built against httpclient 4.x (already pinned in this module) and cannot use them, so they are excluded to keep the driver jar lean. Verified by scanning every class in the built jar: the only classes referencing those packages are libthrift's own five HTTP/servlet classes - nothing in the hive/hadoop stack touches them.
databricks: pin 0.24.0 over the 0.23.0 bundled with databricks-jdbc-thin 3.4.1, following this module's existing pattern of pinning newer transitives (lz4, bouncycastle, jackson, commons-configuration2).
Previously the two driver jars bundled different libthrift versions (0.16.0 and 0.23.0) into the shared plugin classloader; this aligns them on a single version.

Also updates the Spark SQL connection form's "additional JDBC options" placeholder from ;transportMode=http to ;transportMode=binary - binary is the default and supported transport; hive-jdbc's http transport mode is not compatible with libthrift ≥ 0.19 (it passes an httpclient 4.x object to a constructor that now takes httpclient 5.x, see #46793), so the form should not suggest it.
2026-07-31 19:47:33 +08:00
YB
665063e39f [GDGT-2939] Update lz4-java to 1.11.1 in clickhouse and databricks drivers (#79147)
Routine dependency upgrade: bumps the pinned at.yawk.lz4/lz4-java (maintained fork of the discontinued org.lz4:lz4-java) to the latest release, 1.11.1, in the ClickHouse and Databricks driver modules.

Upstream changes between the pinned versions and 1.11.1 are additive only (new fast-reset compressor classes, a linux-riscv64 native binary, bugfixes) — no classes removed, same net.jpountz packages, so it is a drop-in update for both JDBC drivers. Neither driver module calls the library directly.
2026-07-31 19:47:22 +08:00
Timofey Kachalov
fedf6fbe01 Prevent requests with undefined collection id (#78822) 2026-07-31 14:41:25 +04:00
Chris Truter
5426e9a50a Avoid deadlocks when deleting databases without fields (#79048) 2026-07-31 09:52:35 +00:00
Stas Gavrylov
b34f244ffa GDGT-2874: Move AI analytics to Monitor (#78532)
* GDGT-2884: Move AI analytics to Monitor (#78222)

* GDGT-2874: Relocate AI analytics source under monitor/ai-auditing

* GDGT-2874: Add Monitor URL helpers for AI Auditing

* GDGT-2874: Repoint hardcoded legacy paths to Monitor URL helpers

* GDGT-2874: Swap MetabotAdminLayout for MonitorMain in AI Auditing pages

* GDGT-2874: Fix stale audit_app import in metabot-analytics test-support mock

Plan gap: this file wasn't in Task 1's moved-tree scope and wasn't listed as an
external consumer to fix in a later task. Caught during Task 4 verification
via type-check-pure.

* GDGT-2874: Add AI Auditing sections to monitor_section_clicked event

* GDGT-2874: Add canAccessAiAuditing selector and route guard

* GDGT-2874: Add combined AI Auditing routes getter and plugin index

* GDGT-2874: Move AI Auditing plugin wiring from PLUGIN_AUDIT to PLUGIN_MONITOR

* GDGT-2874: Mount /monitor/ai-auditing route block

* GDGT-2874: Add AI Auditing tab to Monitor nav

* Add regression tests for AI Auditing getActiveSection ordering

Lock in the order of the mcp/conversations/usage-stats route-matching
clauses in getActiveSection: the bare Urls.monitorAiAuditing() path
string-prefixes both sub-paths, so the more specific clauses must be
checked first (ts-pattern's P.string.startsWith is first-match-wins).
Without this, a future reorder would silently break active-tab
highlighting for the two sub-routes.

* GDGT-2874: Remove Auditing folder from Admin AI nav

* GDGT-2874: Add legacy redirects for /admin/metabot/usage-auditing

* GDGT-2874: Fix moved AI Auditing spec mocks and path assertions

* GDGT-2874: Repoint slash-command URL helper off the legacy admin path

The Metabot /inspect slash command still built its target URL via
metabase-enterprise/urls.ts's adminMetabotUsageAuditingConversation,
which hardcoded the old /admin/metabot/usage-auditing/conversations
path. It only worked because it bounced through the legacy redirect.
Delete that helper (its only caller) and use the canonical
Urls.monitorAiAuditingConversationDetail directly.

* GDGT-2874: Update E2E specs to canonical /monitor/ai-auditing URLs

usage-auditing.cy.spec.ts and mcp-analytics.cy.spec.ts predate the
Monitor migration and still visited/asserted on the legacy
/admin/metabot/usage-auditing* URLs, which only worked via the legacy
redirect. In-app navigation (row clicks, breadcrumbs, back button) now
lands on /monitor/ai-auditing* URLs, so any post-navigation URL
assertion in these specs was exercising stale paths. Point every
cy.visit()/pathname assertion at the new URLs, and add a small
regression test asserting the legacy URL still redirects, since the
main specs no longer exercise it directly.

* GDGT-2874: Apply AI Auditing cross-review fixes

* GDGT-2874: Add AI Auditing availability placeholders

* GDGT-2874: Gate AI Auditing pages with settings placeholders

* GDGT-2874: Keep unavailable AI Auditing links navigable

* GDGT-2874: Prioritize global AI settings for MCP availability

* GDGT-2874: Fix empty screen layout

* GDGT-2874: Use semantic AI Auditing navbar icons

* GDGT-2874: Restore Audit plugin ownership

* GDGT-2874: Test legacy redirects in app routes

* GDGT-2874: Add canonical Usage stats route

* GDGT-2874: Cleanup

* GDGT-2874: Address PR review feedback

- Rename AiAuditingAvailabilityLayout -> AvailabilityLayouts: the file
  exports two layout components (Metabot and MCP), neither named after
  the file/folder.
- Use !!useSetting(...) for the enabled/configured flags, matching the
  convention used everywhere else these settings are read.

* GDGT-2883: Restyle AI Auditing to match Monitor (#78356)

* GDGT-2874: Fix moved AI Auditing spec mocks and path assertions

* GDGT-2874: Apply AI Auditing cross-review fixes

* GDGT-2883: Restyle AI Auditing to match Monitor

* GDGT-2883: Fix inconsistencies

* Fix issues surfaced by restacking onto gdgt-2874

- Remove index.unit.spec.ts: orphaned artifact from a superseded
  gdgt-2874 draft, testing a ./index module that was never actually
  relocated here (registration logic still lives in audit_app/index.js,
  already covered by routes.unit.spec.tsx).
- Un-mock ConversationHeader in ConversationDetailPage.unit.spec.tsx:
  the blanket mock predated the conversation-title test and was
  hiding the real header it needs to assert against; the endpoint
  mocks setup() already provides make the real component safe to
  render.
- oxfmt fix in ConversationsTable.tsx.

* GDGT-2883: Fix Monitor layout consistency across AI Auditing pages

- Contain the Tool calls grid to the viewport (MonitorMain only clips
  when its parent is a flex container, so wrap it in a Flex h="100%"
  on the events tab; the charts tab keeps natural page scroll)
- Do the same for the Conversations grid page
- Hide the events table's pagination until it has loaded once, instead
  of showing a nonsensical "1 - 0 of N" range
- Add showTotal to the Conversations page's pagination, which was
  missing it entirely
- Match tabs/filters/content spacing (md) to Background Tasks on Usage
  stats and MCP analytics, which had drifted to lg
- Vertically center the ellipsis menu button against the conversation
  title in ConversationHeader

* GDGT-2883: Make all AI Auditing grid columns sortable, fix non-clickable cursor

- TreeTable: only show the pointer cursor on rows that actually respond
  to a click (onRowClick or getRowHref set), instead of unconditionally
  on every row. Fixes MCP analytics' Tool calls grid looking clickable
  when it isn't, and the same latent bug on several other display-only
  TreeTable consumers.
- MCP analytics Tool calls: wire up sorting for the remaining columns
  (ID, Client version, Tenant, IP address, Error type, Error message).
  The query builder already supported ordering by any valid view
  column, so this is purely frontend wiring.
- Conversations: add Title as a sortable column, including the backend
  allow-list/schema entry it needs. Queries and Searches stay
  unsortable — they're computed in-memory per page from message
  content, not real SQL columns.
- Fix a stale field name (summary -> title) in ConversationsPage's
  test mock that was failing type-check.

* GDGT-2883: Show tool call details in a sidebar on the Conversation Detail page

Replace the tool-call-click modal with a Monitor sidebar, matching the
rest of the AI Auditing views. The live Metabot chat widget keeps its
modal — AgentToolCallMessage now takes an optional onSelect callback
that, when provided, defers to the host instead of opening the modal.

Also style the sidebar's Request/Response copy buttons with the same
Copy/Copied! tooltip used elsewhere in the conversation view, and fix
a pre-existing bug where copying re-stringified the already-formatted
JSON text, copying an escaped string instead of clean JSON.

* GDGT-2883: Toggle the tool call sidebar closed on a second click

Clicking the same tool call record that's already selected now closes
the sidebar instead of re-opening it with the same content.

* GDGT-2883: Constrain conversation content to an 800px column, fix page scroll

Wrap the whole page body (header, stats, feedback, conversation, and
generated queries) in a single horizontally-centered 800px column
instead of leaving it full width.

Fix the page-level scroll along the way: the Flex wrapper added for
the tool call sidebar's width measurement gave MonitorMain's parent
display:flex, which made MonitorMain's flex+overflow:hidden CSS
actually take effect and clip content instead of letting the page
scroll. This page has no internal scroll container of its own (unlike
table-driven Monitor views), so drop the bounded height and let it
scroll naturally.

Also add the missing Copy/Copied! tooltip to AgentToolCallMessage's
own copy button, matching the one already added to the sidebar's
Request/Response copy buttons.

* GDGT-2883: Make AI Auditing pagination abortable, sync page in URL instantly

Every paginated AI Auditing grid (MCP analytics Tool calls, Conversations)
kept a stale in-flight request running when the user paged again before it
resolved, and the URL's page param lagged behind clicks by the shared
useUrlState hook's 300ms debounce — unlike Dependency Diagnostics, where
page changes are both abortable and instant.

- Swap McpEventsTable's and ConversationsPage's queries onto
  useAbortableQuery (the same hook DD already uses), so switching pages
  cancels the stale request instead of letting it complete in the
  background.
- Add an opt-in `immediate` option to useUrlState's patchUrlState that
  bypasses the debounce for a single patch, and use it for pagination
  clicks specifically. Filter/sort changes keep debouncing as before —
  this only affects callers that pass the new option.

* GDGT-2883: Sync pagination to the URL instantly across other Monitor list pages

Same delayed-URL-on-pagination-click bug as AI Auditing, found on a sweep
of every useUrlState consumer with a paginated grid: TaskListPage,
TaskRunsPage, NotificationsAdminPage, and ErrorOverview. Pass the new
patchUrlState immediate option on their pagination handlers too.

TaskListPage's pagination test asserted the old debounced timing (URL
unchanged immediately after a click, then advance the fake timer); updated
it to assert the new instant update instead. The other pages' tests only
asserted the eventual URL state, so they needed no changes.

* GDGT-2883: Use the empty-dashboard illustration for the disabled MCP analytics page

Swap the generic metabot icon for the EmptyDashboardBot illustration and
make the CTA a secondary button, matching other Monitor empty states.
Scoped to the MCP-disabled case only — the AI-features-disabled and
not-configured states elsewhere in this file are unchanged.

EmptyState gains an optional actionVariant prop (defaults to "filled",
preserving every other caller's current look).

* GDGT-2883: Apply cross-review a11y/correctness fixes

- Empty-dashboard illustration + secondary CTA now apply to all four
  AI Auditing unavailable states (AI disabled, not configured, MCP
  disabled), not just MCP-disabled — that was the intended scope; a
  narrower revert from a review pass was itself reverted here.
- Give the tool call copy buttons accessible names (were icon-only).
- Make TreeTable sort column headers keyboard-operable.
- Clear the selected tool call when navigating to a different
  conversation, so the sidebar can't reopen with stale data.
- Move focus into the tool call details sidebar on open and back to
  the trigger on close; label it as a region.
- Ignore a hidden/PII-gated sort column from a stale URL instead of
  querying by it.
- Add a Title-sort UI test and a one-shot immediate-bypass regression
  test.

* GDGT-2883: Fix code-review findings in TreeTable and AI Auditing

- Stop sortable header keydowns from bubbling to the treegrid, which
  could also activate a stale keyboard-focused row
- Give every populated TreeTable header the columnheader role, not
  just sortable ones
- Drop the redundant onRowClick push() on conversation rows, which
  double-navigated alongside the row's own link
- Render the MCP analytics loading/empty states inside the active
  Tabs.Panel so the selected tab's aria-controls stays valid

* GDGT-2883: Trim redundant comments in McpEventsTable and ConversationDetailPage

* GDGT-2883: Cleanup

* GDGT-2883: Fix e2e tests

* GDGT-2883: Address PR comments

* GDGT-2919: Migrate CLI usage analytics to Monitor (#78900)

* Project MCP events query to curated PII-aware columns

Restricts buildEventsQuery to exactly the columns the events table
displays, keyed off tenant/PII retention settings, instead of shipping
the view's full row (including ip_address/error_message) to the
browser regardless of the setting.

* GDGT-2919: Migrate CLI analytics to Monitor

* GDGT-2919: Drop ai-features-enabled? gate for CLI analytics

The gate was new, not preserved: the old admin route only checked
audit_app/admin access, with no ai-features-enabled? check, and CLI's
v_agent_api_calls data is independent of Metabot/LLM configuration.

* Fix oxfmt formatting

* GDGT-2919: Bring CLI analytics UI in line with the rest of Monitor

- Move the calls filter bar under the tabs and use MCP's spacing values,
  keeping tabs/filters mounted (and visible) through loading and empty
  states instead of swapping the whole Tabs block out.
- Replace CliEventsTable's AdminDataTable (admin-only, fixed
  maxBodyHeight) with TreeTable, matching McpEventsTable's structure so
  the Calls tab fills the available height like the rest of Monitor.

* Replace withRouteProps with useRouter across Monitor pages

withRouteProps is documented as a temporary v3-router migration shim
that reads location/params via useRouter() and re-injects them as
props. Have the Monitor pages call useRouter() directly instead and
mount them as plain route elements, dropping the wrapper.

* GDGT-2919: Make all Calls tab columns sortable

Matches MCP's events table, where every column (including the PK and
PII columns) is server-sortable. call_id/tenant_name/ip_address/
error_message are all plain columns on v_agent_api_calls, so ordering
by them needs no query changes beyond adding them to the sort list.

* GDGT-2919: Project CLI events query to curated PII-aware columns

buildEventsQuery shipped the view's full row (including ip_address and
error_message) to the browser regardless of the tenant/PII retention
settings; the frontend only hid those columns from the rendered table.
Add cliEventColumnKeys as the single source of truth for both the
query projection (Lib.withFields) and the display columns, mirroring
the same fix already applied to MCP analytics.

* Use absolute imports for cross-module paths in ai-auditing

Replace ../ relative imports (mcp-analytics/cli-analytics reaching
into metabot-analytics, and cross-directory imports within
metabot-analytics) with the metabase-enterprise/... absolute alias.
Same-directory ./ imports are left as-is.

* GDGT-2919: Show an error instead of spinning forever in CliEventsTable

useCliEventsQuery didn't surface the query's error, so a failed
per-page fetch left data undefined and the table stuck on the
skeleton indefinitely. Surface the error and render
LoadingAndErrorWrapper instead.

Also drop useRetainedValue: RTK Query's data (unlike currentData)
already stays sticky across page/sort changes until the next result
resolves, so the manual retention was redundant.

* GDGT-2919: Show an error instead of spinning forever in McpEventsTable

Mirrors the CliEventsTable fix: useMcpEventsQuery now surfaces the
query error, and McpEventsTable renders LoadingAndErrorWrapper instead
of leaving the skeleton up indefinitely on a failed fetch. Drops
useRetainedValue there too, for the same reason (RTK Query's data is
already sticky across page/sort changes).

Also trims redundant comments in both event tables.

* GDGT-2919: Address PR comments

* Revert gratuitous DependencyDiagnosticsPageOwnProps rename

Keep master's DependencyDiagnosticsPageProps naming so this Graphy-owned
file isn't touched by the master merge for no functional reason.

* Fix jest.mock("metabase/AppComponent") to match its named export

Master's router refactor changed AppComponent from a default-exported
connect() component to a plain named export (App). The mock still
returned a default export, so import { App } resolved to undefined and
broke every route render in routes.unit.spec.tsx.

* GDGT-2874: Fix usage audit e2e test
2026-07-31 12:42:13 +03:00
Fraser
3c59e2dacb Use viz-local VisualizationGridSize in treemap labels (#79017)
The treemap label model imported VisualizationGridSize from the
enterprise custom-viz package, a cross-boundary type import. A
structurally identical type already exists in
metabase/visualizations/types, so use that instead.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:32:19 +12:00
Timofey Kachalov
b75261aba2 Improve Data Apps sandboxing (#78938) 2026-07-31 11:37:43 +04:00
Romeo Van Snick
9a3add685a Host the app on the react-router data router (#78655)
* Split getRawBrowserHistory out of blocking-history

* Host the app on the react-router data router
2026-07-31 16:31:00 +10:00
Romeo Van Snick
aaada18ca8 Delete RouterBridge and collapse the router facade onto v7 (#79130)
* Resolve relative navigation against v7's own match

* Resolve redirect targets off the v7 match branch

* Drop the router argument from useDashboardUrlQuery

* Free Palette and McpAnalyticsPage from the router context

* Read location and params from v7 instead of useRouter

* Scope route-leave hooks from the match instead of an injected route

* Delete RouterBridge, the router contexts, and the v3 route types
2026-07-31 15:35:08 +10:00
Timothy S. Dean
5a232bb5c8 sql-jdbc: stop munging dashes in schema-qualified table identifiers (#78969)
HoneySQL munges dashes to underscore in a keyword's namespace, but not in its name. The code was relying on HoneySQL default behavior to turn namespaced keywords into table references, but the inconsistency bit me in some new development work.

Two paths currently pass schema-qualified keywords in cases where a table might leak:
- `drop-table!` targeted a nonexistent object and silently "succeeded" (not dropping the table)
- `compile-transform` (the SQL variant) could target a nonexistent database.

Both `drop-table-sql` and `create-table-sql` are updated (the latter via `quote-table`) to keep them in sync.

Collapsing the namespace into the keyword's name keeps everything verbatim as you'd expect. Callers already passing in `"schema.name"` weren't impacted.
2026-07-30 20:33:24 -06:00
Alexander Polyankin
e3e3a193f8 Fix QB perf (#79132)
* Fix QB perf

* Forbid loading every field via GET /api/database/:id/metadata

Make skip_fields a required `true` on the FE request type so no caller can ask
for field-level metadata, which grows with every column in the database.

The two callers that needed fields now scope their fetch to a single table:
- Data Reference field pages fetch the table's own query metadata (which also
  pulls FK target tables and their fields)
- useAuditTable resolves the table from the plain database response, then loads
  /api/dataset/query_metadata for that table

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Withhold the audit table until its fields load

ConversationStatsPage renders charts straight from useAuditTable's table
without checking isLoading, so handing back a table built from the field-less
database metadata made it paint once and re-render when the ad-hoc query
metadata arrived, detaching the chart nodes mid-assertion.

Also make the segment field list spec's database mock honour skip_fields, so
unit tests can catch a caller that stops asking for fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix be

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:22:06 -04:00
appleby
0a303f2dec Add Mistral metabot provider (#78712)
* BE Add Mistral metabot provider

* BE Add prompt-cache-key to LLMRequestOpts for Mistral prompt caching support

Mistral requires a `prompt_cache_key` to be set to enable prompt caching, so add it as an optional key to
`LLMRequestOpts` and thread it through `self/call-llm` and `self/call-llm-structured`.

* FE Add Mistral metabot provider

* Review feedback: fold all provider selectable tests into a single test

* Review feedback: use findPutRequests to reduce verbosity
2026-07-31 02:13:34 +00:00
appleby
75fb5a66ee Add Z.AI metabot provider (#78521)
* BE Add Z.AI metabot provider

* FE Add Z.AI metabot provider

* Move generic chat-completions code to metabase.metabot.self.openai.chat-completions

And use it in both the openrouter and zai providers.
2026-07-30 17:40:02 -07:00
Fraser
e491dcef5c Move search term hashing into the analytics module (#79124)
hashSearchTerm and shouldReportSearchTerm are analytics reporting
policy (settings read + SHA-256 hashing), not search UI, so they move
from common/search/term.ts to the lib-tier analytics module and are
exported from its barrel. Consumers now import them from
metabase/analytics, and the common/search barrel no longer re-exports
them.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:38:41 +12:00
Fraser
1e441d46c4 Re-tier routes-stable-id-aware from shared to app (#79116) 2026-07-31 12:38:25 +12:00
Fraser
0f1d14da69 Move funnel data types next to calculateFunnelSteps (#79122) 2026-07-31 12:38:13 +12:00
Fraser
ce12880118 Remove dead MetabotFooter component (#79114) 2026-07-31 12:37:57 +12:00
Fraser
2b0ed37c02 Move geojson endpoint into admin/settings (#79123) 2026-07-31 12:37:40 +12:00
Fraser
e61302efef Move question loader components into questions module (#79125) 2026-07-31 12:37:26 +12:00
Fraser
d7e7d184af Move isVisualizerDashboardCard into metabase-types/guards (#79120)
Relocate the isVisualizerDashboardCard type guard from
metabase/visualizer/utils to metabase-types/guards/dashboard, alongside
the other dashboard type guards, and update all importers to the new
path. Remove the re-export from the visualizer utils barrel and delete
the old file.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:37:12 +12:00
appleby
0ec7cdb8f9 Enable Mistral Medium 3.5 via OpenRouter (#78644) 2026-07-30 23:43:17 +00:00
Romeo Van Snick
8594eb45c7 Move the location.query readers to search params (#78670)
* Add parseSearchQuery and a useNavigationType facade hook

* Move the location.action readers to useNavigationType

* Move the location.query readers to search params

* Drop query and action from the facade Location
2026-07-31 09:07:55 +10:00
Romeo Van Snick
b4772edd33 Delete withRouteProps (#78779)
* Replace connect() with hooks in the app shells and permissions pages

* Rewrite the reference containers as function components

* Delete withRouteProps

* Pass the props reference children read but do not select

* Cover every reference container page with a render test

* Fetch reference container data before the child's first render
2026-07-31 09:07:36 +10:00
appleby
747194fddf Enable GLM-5.2 and Deepseek V4 Pro via OpenRouter (#78253) 2026-07-30 15:34:31 -07:00
Anderson
70a9109bf3 Track Dimensions tab views and dimension add/remove/reorder/edit events for metrics (#78721)
* Add Snowplow event tracking to the metric Dimensions tab

* Update events' definitions to comply with new approach

* Update analytics-events claude skill to account for new approach defined in #71631
2026-07-30 19:24:35 -03:00
Alexander Polyankin
a6a112c2ab Skip fields when browsing tables of a schema-less database (#79101)
TableBrowser only needs the table list, but GET /api/database/:id/metadata
returns every field of every table. Pass skip_fields=true so the response
stays proportional to the number of tables.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:27:53 -04:00
Anderson
06a0c8dcbc Allow setting custom time buckets for metric time dimensions (#79046)
* Allow setting custom time buckets for metric time dimensions

* Code cleanup

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-07-30 21:23:37 +00:00
Alexander Polyankin
860f2beff2 Make the permission check a hash lookup instead of a scan of every granted path (#79076) 2026-07-30 17:07:49 -04:00
Nick Fitzpatrick
3ceaa9583a 2FA Enrolment pages and reset (#78837)
* Enrolled / Unenrolled pages, routes

* Alessio Feedback

* settings card big title

* adjustments

* additional tests

* Kevin feedback

* braden feedback
2026-07-30 17:58:52 -03:00
Timothy S. Dean
959c2fca82 sql-jdbc: bind boolean insert values as parameters (#78864)
HoneySQL inlines true/false as TRUE/FLASE tokens even when the query is otherwise parameterized, and not every driver target has a boolean literal. SQL Server parses a bare TRUE as an identifier and `insert-into!` fails with `Invalid column name 'TRUE'`. Wrapping boolean row values in `[:lift v]` binds them as paremeters, which is what we do for other value types.

Impacts every `:sql-jdbc` driver's `insert-into!`. Drivers with boolean literals are not impacted.
2026-07-30 13:57:50 -06:00
Alexander Polyankin
60cbd1c85c Fix quadratic :is_personal hydration that made GET /api/collection ta… (#79073)
Fix quadratic :is_personal hydration that made GET /api/collection take ~23s

On an instance with 28k collections — 6k of them personal — `GET /api/collection`
took 23.5s while making only 13 DB calls. Nearly all of it was the `:is_personal`
batched hydration: for every collection it scanned the full set of personal
collection IDs, building a prefix string with `format` on each step, so a request
did ~20k x 6k = 125M `String/format` calls.

Personal Collections only ever live in the Root Collection, so a collection is
inside one exactly when the *first* ID of its location path is a personal
collection. Test that single ID against the set instead of scanning.

Measured on a seeded app DB matching the reported shape (28.6k collections, 6,011
personal roots, 13.6k live non-personal, 7.8k archived, depth to 13):

    GET /api/collection   11.2s -> 1.9s
    :is_personal hydrate   9.2s -> 75ms

The remaining hot spot is `:can_write` (~1.1-1.5s), which linearly scans the
user's permission set per collection — left for a separate change since it
touches the shared permission check.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:43:01 -04:00
Oleksandr Yakushev
714d6ceae1 [malli.defn] Don't gensym internal fn name twice (#78083) 2026-07-30 22:33:49 +03:00
Andrei Chernykh
af747a5b36 chore: Convert the store factory and whitelabel color bootstrap to TypeScript (#78618)
* Convert the store factory and whitelabel color bootstrap to TypeScript

* Name the deliberately loose reducer map type

* Condense the reducer map comment
2026-07-30 11:03:18 -07:00
da
ce9982a7e6 BigQuery: reject oversized queries client-side with a humanized error (#79020)
* BigQuery: reject oversized queries client-side with a humanized error

BigQuery rejects jobs whose SQL exceeds 1 MB (1,048,576 characters,
including comments and whitespace) with a raw 400 INVALID_ARGUMENT,
which Metabase surfaces to the user as "Error executing query: <raw
message>" -- accurate but technical and not actionable.

Check the final compiled SQL (including the appended remark) before the
BigQuery client/job is created and throw a localized, actionable
:invalid-query error instead. The raw DB error path remains as a
fallback if this limit ever drifts from BigQuery's own.

Quota reference: https://cloud.google.com/bigquery/quotas#query_limits

Closes #79019

* Update error message for SQL query length validation

* Address review: express limit as (* 1024 1024), slim the tests

- max-sql-query-length-chars: write the value as (* 1024 1024) so the
  1 MiB intent reads off the page.
- validate-query-length-test: collapse to the two boundary cases (at the
  limit allowed; over the limit throws :invalid-query), error type only,
  per review feedback to keep it simple.

* Address review: trim noise from max-sql-query-length-chars docstring

Drop the cross-reference to validate-query-length! and the "not ^:const"
justification — commentary, not contract. Leaves what the constant is,
how BigQuery counts, the failure mode, and the quota link.
2026-07-30 17:33:51 +00:00
Maksym Yakubych
ecf701f7f6 Add AI provider step to onboarding flow (#78001)
* Add AI provider setup to onboarding

- Insert AI config step into setup flow
- Track connect and skip analytics events
- Update onboarding tests and schema version

* Preserve existing AI provider during setup

- Wait for the saved provider before mounting the form
- Show the connected provider in the setup step and cover it in tests

* Defer AI provider sync until after setup

- Keep the AI config step visible during onboarding
- Skip legacy Metabot provider migration on fresh instances

* Add AI provider setup to onboarding

- Hide the step when AI features are disabled
- Support Anthropic onboarding setup in e2e
- Mock Anthropic model catalog for credential validation

* Match license step layout in AI setup step

* Fix modules config and flaky setup toast assertion

* Add free_units to managed AI e2e add-on fixture

* Condense AI step ordering comment

* Make AI setup opt-in from the completed step

The AI provider step is no longer part of the wizard by default. The
"You're all set up!" screen offers a "Set up AI" button that appends
ai_config as the last step; connecting or skipping returns to it.

* Scope the settings-list mock to the test that needs it

settingOverrides was only used by one embedding test, so drop it from the
shared setup helper and let that test override the settings-list route.
2026-07-30 17:15:21 +00:00
Andrei Chernykh
e4b496929a chore: viz registry and dashboard viz types (#78840)
* Type the visualization registry as components or bare definitions

* Convert the dashboard viz register module to TypeScript and use typed named exports for Action and IFrameViz
2026-07-30 09:29:46 -07:00
Denis Berezin
37a387ab60 UXW-4572 Disable greeting when conceal settings is on (#78973) 2026-07-30 19:24:31 +03:00
Denis Berezin
130d93223d UXW-4935 Fix long metric names overflow in browse/metrics table (#78958)
* UXW-4935 Fix long metric names overflow in browse/metrics table

* Add e2e check
2026-07-30 19:23:07 +03:00
Timothy S. Dean
7d1b01bac5 SQL Server: create :type/Text columns as nvarchar(max) instead of text (#78861)
`text` is non-Unicode, and it cannot be compared, sorted, or grouped. It's also deprecated (https://learn.microsoft.com/en-us/sql/t-sql/data-types/ntext-text-and-image-transact-sql?view=sql-server-ver17). `nvarchar(max)` is unbounded, Unicode, and not deprecated.

Affects transform target tables. Existing tables keep their text columns until a transform recreates them.
2026-07-30 10:07:27 -06:00
Alexander Polyankin
9d391efca1 Strip large literal lists (IN, tuple IN, ARRAY) before GraalPy SQL parsing (#79028)
* Strip large literal IN lists before GraalPy SQL parsing

Large literal IN lists (like large VALUES clauses) blow GraalPy's ~114KB/AST-node
memory cost and its 30s call timeout: a 300k-item list that sqlglot parses in
1.7s on CPython times out at 30s on GraalPy, poisoning a pooled context on every
background dependency-analysis pass over the offending query.

Extend the JVM-side pre-processing to also replace literal-only IN lists with
more than 100 items with `IN (NULL)`. Only lists consisting purely of literals
(numbers, quoted strings, NULL/TRUE/FALSE) are stripped; column references,
function calls, subqueries, and bind parameters disqualify a list, since
stripping those would change analysis results or parameter counts. Lists nested
inside unstripped IN subqueries are still found.

All callers get the same handling: the stripping happens in the one shared
strip-large-literal-lists (renamed from strip-large-values) used by every
analysis entry point, and is-single-stmt-of-type? keeps the #74284 restore
behavior — validate the stripped SQL, return the original text — so
impersonated queries still execute with their literal lists intact.

With the fix, field-references on a 300k-item IN list drops from a 30s timeout
to ~80ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify literal-list stripping to a single shared pass

Review cleanup: collapse the two per-keyword scanners into one walk over the
SQL driven by a combined VALUES|IN pattern with small per-keyword handlers,
extract the quote/doubled-quote scanning into a shared skip-string-literal
(also fixing count-top-level-commas, which didn't handle doubled-quote
escapes), and replace the substring-allocating literal-only-list? check with a
single index-based early-exit pass, so IN (SELECT ...) is rejected without
scanning or copying the subquery region. No-strip calls now return the
original string identity.

field-references on a 300k-item IN list drops from ~80ms to ~27ms; all
existing stripping tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add exhaustive tests for literal-list stripping

Cover threshold boundaries (100 vs 101) for both VALUES and IN, every accepted
literal shape, a disqualifier grid (17 non-literal items x 3 positions),
byte-for-byte preservation of surrounding SQL, nesting (IN inside VALUES
tuples, stripped-VALUES swallowing nested lists, deep IN-subquery chains),
idempotency, original-instance identity on the no-strip path, malformed input
(unbalanced parens, unterminated strings, empty lists), keyword false
positives (margin, JOIN, INT, quoted identifiers), and an end-to-end pass of
every analysis entry point over stripped SQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Also strip large ARRAY literals and literal tuple IN lists

Two more static-data shapes reproduce the GraalPy 30s timeout / severe CPU
burn: id = ANY(ARRAY[...]) (the Postgres idiom ORMs emit instead of IN; 200k
items hit the 30s timeout) and composite-key tuple lists
(a, b) IN ((1, 1), (2, 2), ...) (50k tuples burned 21s).

Generalize the flat literal-list scanner to a parameterized closing delimiter
and reuse it for ARRAY[...] -> ARRAY[NULL], and add a tuple-list scanner that
requires every tuple to be literal-only, stripping to IN ((NULL, ...)) with
the first tuple's arity. Subscript access (arr[1], arr[1:5]), subquery tuples,
and any non-literal item still disqualify a list. The is-single-stmt-of-type?
restore behavior covers both new shapes.

After the change: tuple-IN 50k tuples 21.6s -> 525ms; ANY(ARRAY[200k])
30s timeout -> 1.1s. CASE WHEN chains and UNION ALL literal tables remain
unstripped (no lossless strip exists for them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat double quotes as a disqualifier in literal-list scanning

In identifier-quoting dialects (Postgres, Snowflake, ...) "x" inside an IN
list or ARRAY literal is a column reference, not a string, so stripping a
large list of double-quoted items would drop real field references from
analysis. The literal-list scan is dialect-agnostic, so be conservative: only
single-quoted strings count as literals; a bare double quote leaves the list
untouched. skip-string-literal still skips both quote styles where it is used
for paren/comma balance scanning, where either interpretation is a
skip-region.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use [[strip-threshold]] docstring links instead of backticks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix Eastwood reflection warning: cast close-ch to primitive char

(= ch close-ch) compared a primitive char against the untyped close-ch
parameter, which Eastwood flags as an unresolvable Util.equiv call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Ignore keyword matches in strings/comments; reject empty list elements

Review follow-ups:

The keyword matcher previously rewrote text inside string literals and
comments — SELECT 'IN (0, ..., 149)' became SELECT 'IN (NULL)', and an
unbalanced quote inside such a region could corrupt the statement. The walker
now gap-scans between matches with a string/comment-aware cursor
(code-region-end) and copies any match that sits inside '...', "...",
-- ..., or /* ... */ through untouched.

The flat-list scanner also accepted empty elements, so invalid SQL like
IN (,,,) with >100 commas was rewritten into valid IN (NULL), masking syntax
errors from validation. Both the flat and tuple scanners now require content
in every element and reject leading/trailing/consecutive commas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify IN/ARRAY stripping to one simple-list scanner

Stripping feeds fail-soft analysis only, and execution paths restore the
original SQL, so the defensive machinery accumulated from review rounds
(literal word allow-list, tuple/arity validation, empty-element checks,
string/comment-aware outer scanning) bought correctness for cases that don't
matter at the cost of real complexity. Replace it all with one rule: a list is
strippable when, from its opening ( or [, it contains only digits, signs,
single-quoted strings, commas, whitespace, and balanced parens (which covers
tuple lists with no special logic), and holds at least 100 commas. Flat IN,
tuple IN, and ARRAY all collapse into a single ~20-line scanner and one
decision fn; NULL/TRUE/FALSE items now conservatively block stripping. VALUES
keeps its pre-existing count-based path. The best-effort contract is stated in
the public docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:58:31 -04:00
Timothy S. Dean
8f6204a41f Consolidate SQL string literal escaping (#78859)
Seven sites across four drivers were escaping SQL string literals the same way. This consolidates, and there's a single place to warn not to use escape-sql to sanitize user input.

No behavior changes.
2026-07-30 09:27:40 -06:00
Timothy S. Dean
4a260bbabb BigQuery: map java.math.BigInteger to INT64 in query parameters (#78844)
MBQL treats `java.math.BigInteger` and `clojure.lang.BigInt` interchangeably. Only the Clojure class was in the parameter table, so a BigInteger fell through in one of my tests to `:default` and bound as a string.
2026-07-30 09:27:03 -06:00
Nemanja Glumac
36b8479c2a Bump the init timeout to 120s (#79014) 2026-07-30 16:17:16 +02:00
Nemanja Glumac
ad16b000c8 Remove superfluous flaky e2e test (#79026) 2026-07-30 16:13:33 +02:00
Stas Gavrylov
477deaced9 Improve frontend TypeScript skill loading (#79009)
Update frontend skills to improve typescript-write referencing
2026-07-30 15:56:08 +03:00
Chris Truter
4fa4f822e6 Add pgvector store health metrics (#78668) 2026-07-30 12:34:26 +00:00
Egor Grushin
91c76f0f41 Ghy 4210 show the actual output destination for a transform run (#78874)
* Show the output schema and table for a transform run

Adds a TransformOutput component that renders the run's target as `schema › table`
breadcrumbs with a copy button. Both crumbs link out when they exist: the schema to
the data model, the table to the query builder. Wired into the run sidebar
("Output table" row) and the job run sidebar ("Output:" line).

Also fixes the job run sidebar not shrinking below its content width (missing
min-width: 0), which let long table names push it past the viewport.

* fix tests

* drop newTab prop, always open output links in new tab

* addressing PR's comments

* fix race condition
2026-07-30 13:20:06 +01:00
Chris Vandoodewaard
0efdb86767 add missing env variables so S3 path resolves (#79016)
* add missing env variables so S3 path resolves

* fix linting error
2026-07-30 07:43:57 -04:00
Phoomparin Mano
6db26cc0df chore(sdk): bump sdk version and use 64-alpha dist tag for data apps (#79011)
* bump sdk version with 64-alpha dist tag for data apps

* simplify message on why you should not use 63-stable
2026-07-30 17:37:32 +07:00
Romeo Van Snick
588be2a1eb Make metabase/router a basic-tier module (#78529)
The three preceding PRs removed every shared-tier import from the module: the
route guards, the redux location mirror, and the styled-Link re-export. All that
is left is metabase/utils, which basic may import.

basic rather than lib: router is a React component library, the same shape as
metabase/ui, and nothing in basic/* imports it, so lib would only widen what
lib/utils is allowed to reach for no gain.
2026-07-30 20:27:07 +10:00
Nicolò Pretto
24720615ab fix(sdk): sdk crashes when resizing column (#78794)
* fix(sdk): sdk crashes when resizing column

* do the same on ExecutionOutputTable too

* reword test to make it better

* make sure cols/rows are not new arrays every render when output is undefined
2026-07-30 12:18:20 +02:00
github-automation-metabase
e225496665 chore(sdk): bump SDK version on master to 0.64.0-alpha.0 (#79000)
chore(sdk): bump SDK version to 0.64.0-alpha.0 (alpha)
2026-07-30 10:02:15 +00:00
Stas Gavrylov
850763504b Derive the Tabs value type from the tab values (#78934)
* Derive Tabs value type from the tab values

Mantine types the Tabs value and onChange argument as `string | null`. Make
TabsProps generic over the tab value so callers get their own union back in
onChange instead of a bare string.

* Drop Tabs onChange type workarounds at call sites

Every `<Tabs>` call site now annotates its own tab union instead of casting or
re-narrowing the change value: the six `as SomeTab` casts (CommentsSidesheet,
PythonEditorResults, GroupLimitsSettingsSection, McpAnalyticsPage,
CliAnalyticsPage, ConversationStatsPage), the four in the admin/permissions and
tenants listings, and the handlers that laundered `string | null` through a
lookup or a literal comparison (AddDataModal, NotificationsTabs, TableSection,
the date pickers, TabPanel). AddDataModal's `isValidTab` guard existed only for
this and is gone; DatasetFieldMetadataSidebar's TAB map is now `as const`.

* Cleanup

* Apply suggestions from code review

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>

---------

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
2026-07-30 12:09:05 +03:00
Romeo Van Snick
55ab9b7e38 Make metabase/router side-effect free (#78882)
* Mark metabase/router as side-effect free for rspack

* Route deep metabase/router imports through the barrel

* Let static-viz import the router barrel instead of a deep module

* Guard the side-effect-free contract and scope the include to the directory
2026-07-30 19:01:09 +10:00
Ngoc Khuat
e415e8d14c Revert "Serialize card last_used_at so imports don't reset it" (#78991) 2026-07-30 08:33:40 +00:00
Romeo Van Snick
4a793a1294 Stop re-exporting the app's styled Link from metabase/router (#78528)
metabase/router/Link.ts re-exported metabase/common/components/Link so every
routing import had one home. But that Link is not a routing primitive: it wraps
RouterLink with a Tooltip from metabase/ui, a CSS module, and disabled/variant
props, so the barrel dragged shared/common and basic/ui into the router module
and looped back into router/router-link.

Call sites import it from metabase/common/components/Link again. RouterLink and
RouterLinkProps stay in metabase/router, which is where the primitive belongs.

The boundaries linter never flagged this: element-types checks imports, not
'export ... from'. So the edge had to go rather than be waived, or a lower tier
would have been green while the dependency was still there.
2026-07-30 17:28:40 +10:00
Arik Aronov
245e44542a new team member - enthym_aa (#78935) 2026-07-30 10:14:03 +03:00
Alexander Polyankin
70c3f15684 Fix N+1 query patterns with permissions (#78919)
* Fix N+1 query patterns in GET /api/database and GET /api/native-query-snippet

GET /api/database?saved=true&include=tables issued ~6-7 app-DB queries per
saved question (one metabase_database select-one plus a field select and
4-5 hydrations each) — ~3000 queries on instances with a few hundred
eligible cards:

- card->virtual-table: only fetch the database when include-database? is
  set, instead of unconditionally per card
- extract cards->card-id->metadata-fields so the result_metadata fields of
  all cards are fetched and hydrated in one batch (reused by
  batch-fetch-card-query-metadatas), and pass it from cards-virtual-tables
- ids-of-dbs-that-support-source-queries: one select instead of
  select-pks-set + per-id select-one

GET /api/native-query-snippet ran user-has-any-perms-of-type? — an
uncached EXISTS against data_permissions — once per snippet via
mi/can-read?, even though the answer is per-user, not per-snippet:

- user-has-any-perms-of-type? now primes the request-scoped permissions
  cache for all databases (the same pattern query-creation-capabilities
  already uses on GET /api/user/current) and answers by scanning the
  cached entries; repeated calls in one request are pure cache hits
- prime-all-dbs-cache records completeness with an ::all-dbs sentinel in
  the existing :db-ids set so the database id list is fetched only once
- the uncached EXISTS path is kept for cross-user checks and
  disable-perms-cache

Note: the cache prime excludes permission rows for inactive tables, so a
grant existing only on a deactivated table no longer satisfies
user-has-any-perms-of-type?.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Track full-prime state under a dedicated :all-dbs-primed? cache key

Move the all-databases-primed marker out of the :db-ids set (where it sat
as a ::all-dbs sentinel among real database IDs) into its own
:all-dbs-primed? key on *permissions-for-user*. prime-db-cache now
preserves unrelated keys by assoc-ing instead of reset!-ing the atom.

Make prime-all-dbs-cache public with an optional pre-fetched-ids arity,
and switch query-creation-capabilities — the one caller that manually
primed all databases — to use it, so the full-prime work is shared with
user-has-any-perms-of-type? within a request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify prime-all-dbs-cache: single arity, :all-dbs? key

Drop the pre-fetched-ids arity — the function always fetches the database
ID list itself — and rename the completeness marker from :all-dbs-primed?
to :all-dbs?.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align :all-dbs? docstring continuation line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add call-count regression tests for the two fixed N+1 endpoints

Both tests measure warm app-DB call counts via t2/with-call-count at two
entity counts and assert the count doesn't scale:

- GET /api/database?saved=true&include=tables with 2 vs 18 eligible cards;
  allows the 1-per-card toucan2 identity-query overhead from
  do-after-select but fails on the pre-batching one-select-per-card
  behavior (verified failing against the pre-fix code).
- GET /api/native-query-snippet with 2 vs 10 snippets as a non-admin;
  fails on the per-snippet permission EXISTS check (verified failing
  against the pre-fix code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use mt/with-model-cleanup in call-count tests

Replaces the try/finally + manual delete pattern, so fixture rows are
cleaned up even when insertion or the warm-up request throws (addresses
review feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rework any-perms-of-type caching to a dedicated request-scoped cache

Instead of priming the full permissions cache for every database and
scanning it, cache per [user-id perm-type] the set of database IDs
granting the most permissive value, loaded across all databases in a
single query. This answers every :exclude-db-ids variant from one small
fetch and keeps semantics identical to the uncached query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perms cache

* nil fix

* fix

* current state

* cleanup

* Test that inactive-table-only grants are ignored by aggregate perm checks

user-has-any-perms-of-type? previously counted grants existing only on
inactive tables (its raw EXISTS query had no active-table filter); now
that it is served by the distinct-perms cache it ignores them, matching
every other cached check. This is intentional — cover it with a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Apply code-review fixes to the permissions cache rework

- Restore batch priming in with-can-run-adhoc-query via a new public
  prime-full-perms-cache (table-level checks otherwise load the cache
  one database at a time, up to 2 queries per database on card lists)
- Scope the non-cached distinct-perms load to one database instead of
  scanning all of the user's permission rows per call
- Extract the shared FROM/JOIN/WHERE of the two cache queries into
  perm-rows-query-base so their row sets cannot drift apart
- Simplify the full-perms interner binding and drop a dead fnil and a
  redundant double-check inside the distinct-perms swap!
- Select only :id and :engine in ids-of-dbs-that-support-source-queries
  (full rows decrypt :details per database, twice per request)
- Own the batched virtual-table Field fetch in a new
  cards->virtual-tables entry point; the helper is private again
- Use mt/with-dynamic-fn-redefs in permission-caches-load-per-type-test
- Document that the request caches are point-in-time snapshots

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat cached false as a hit in fast-interner

or-based lookups skip false, so interning false re-ran canon-fn and
putIfAbsent on every call (result was still correct). Use if-some so
presence, not truthiness, decides the hit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document why distinct perms load all databases and why the uncached interner exists

Capture the review-thread answers in the docstrings: distinct-perms
preloads every database because most consumers ask cross-database
questions, and full-perms builds a per-call interner even without a
request cache because group values are highly repetitive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Batch-prime the full perms cache in the remaining per-table check loops

Endpoint tracing found three more loops that fill the full permissions
cache one database at a time now that priming is on demand: the
include=tables branch of GET /api/database (per-table can-read?/
can-query?/can-write?), batch-fetch-query-metadatas* (per-table
can-read? behind dashboard/card query_metadata), and the EE
filter-tables-by-data-model-perms loop. Each now batch-primes the perm
types it checks, one query per type instead of up to 3 per database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify perms caches to all-types loading; rename to table/schema caches

Per-type bookkeeping wasn't worth it — nearly every consumer needs
several permission types, and there are only a handful. Both caches now
load every permission type at once: the table-level cache (was
*full-perms-cache*) per database, the schema-level cache (was
*distinct-perms-cache*, table_id collapsed so schema is the finest
granularity it answers) for all databases in a single query on first
use. prime-table-perms-cache takes just database IDs and no-ops for
superusers, whose checks never read the cache.

Also batch-prime the remaining per-table check loops found by a
repo-wide audit: search result filtering, GET /api/table, recent views
(also reusing the already-fetched table instead of re-selecting it),
typed-schemas table listings, and the segment/measure list endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the search prime and scope the recents prime to actual table recents

Benchmarks on a 10M-row data_permissions Postgres (200k rows for the
test user) showed both primes regressing their endpoints: search went
40ms to 840ms because priming all databases loads every permission row
of the user on each request while matching results only touch a few
databases, and recents primed the whole instance because table-recents
returns every table when given no IDs. Search now relies on lazy per-db
loads; table-recents returns nothing for empty IDs so the prime no-ops
naturally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Load the schema perms cache per database, with a priming function

Benchmarks showed the all-databases SELECT DISTINCT costing ~230ms per
request at 200k user rows, taxing endpoints that ask about one database
(dashcard query 228ms -> 445ms). The schema cache now mirrors the table
cache: single-database questions load one database at a time (indexed,
all permission types at once), loops over many databases call
prime-schema-perms-cache, and cross-database questions load everything
once, recorded under :all-dbs? so repeated checks stay one query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Load perms caches via next.jdbc/plan instead of toucan2 rows

JFR allocation profiling showed toucan2's per-row machinery (row delays,
transient instances, realize maps) dominating the cache loads' share of
request allocation. plan reads columns straight off the ResultSet with
no per-row maps; the folds are unchanged. Runs on the current toucan2
connection so transactional writes stay visible. On the 10M-row bench
this cuts per-request allocation ~10-15% further (e.g. /api/user/current
570MB on master -> 376MB) and 50-90ms of latency on load-heavy
endpoints. Note plan queries are invisible to t2/with-call-count, so
one test now spies on the load fn instead of counting calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Store perms caches as mutable Java collections, skipping persistent copies

The folds now build nested HashMaps/HashSets that the caches store
as-is: no per-row persistent update-in churn and no whole-structure
persistent copy at the end. Clojure's lookup/seq functions read Java
collections directly, so consumers are unchanged. Only the two outer
levels (perm-type, db-id) are ConcurrentHashMaps that later loads extend
via putAll of missing databases; everything below is written once by its
load and then only read. Numeric keys are canonicalized to Long at both
fold and lookup time — JDBC hands back Integer for int columns and Java
maps compare boxed keys with equals, not Clojure's equiv. The entry
interner is gone with the persistent copy; instances with thousands of
identical per-database profiles lose that sharing (#76077), which is the
trade-off for the allocation win.

On the 10M-row Postgres bench (200k rows for the user) vs master:
/api/user/current 570 -> 268 MB allocated per request, GET /api/database
?saved=true&include=tables 1773 -> 1032 MB and 4.1s -> 2.5s,
GET /api/table 1152 -> 865 MB and 1.9s -> 0.9s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Answer cross-database permission questions from a light per-function cache

Instances with thousands of databases and groups make loading tuples
for every database (schema cache with :all-dbs?) scale badly: the
distinct tuple set alone reaches hundreds of thousands of rows. The
questions that actually span databases only need one value per database
for one permission type, so most-permissive-database-permission-for-user
(signature unchanged) and user-has-any-perms-of-type? now share
*most-permissive-perms-cache*: {user-id {perm-type {db-id value}}},
loaded by one indexed query returning distinct (database, value) pairs
and coalesced per database. The :all-dbs? machinery is gone and
query-creation-capabilities needs no priming — its per-database loop
hits the cache after one query.

On the 10M-row bench this takes GET /api/user/current from 722ms/268MB
allocated to 556ms/182MB — 5MB above the response-validation floor
measured with all permission checks short-circuited (master: 833ms/570MB).

Also reorganize the caching section (atoms, then loaders, then the
functions using them; coalesce moves above since a loader needs it) and
relax the xrays call-count expectation: permission-cache loads go
through next.jdbc and are invisible to t2/with-call-count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Coalesce permissions in SQL and give every check its own per-function cache

Each permission-checking function now owns one atom, one hand-written
aggregate query, and a cache structure fitting exactly its question,
organized per function. Values map to ranks (value-rank-case) and
GROUP BY collapses tables, duplicate values and — via two nested levels
of aggregation for the most-restrictive-within-group-then-most-
permissive-across-groups questions — group identity too, so result sets
scale with tables/schemas/databases rather than raw row counts
(dbs x groups x tables x values). A bucket's [min max] rank pair
reconstructs coalesce exactly, verified exhaustively per permission
type including :perms/view-data's special rule; full-schema keeps its
per-group db-level union by expanding db-level rows into every schema
inside its query. native-download delegates to full-db-permission
(identical semantics), user-has-any derives from the most-permissive
cache.

With the row-count multiplier gone the loads are back to plain t2/query
and persistent Clojure collections: the next.jdbc/plan machinery, the
mutable Java collections, the Long key canonicalization, and the
interner are all gone, and cache loads are visible to
t2/with-call-count again. No public API changes; the batch primer is
now prime-db-perms-cache and warms every per-database cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop a docstring reference to the removed full-perm-rows

The ~15x realize-before-key-access observation stands on its own; the
function it pointed at no longer exists now that the permission caches
load SQL-side aggregates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rewrite app-db NOT IN (SELECT ...) queries as NOT EXISTS anti-joins

NOT IN (subquery) degrades to a per-row subplan on Postgres once the
subquery result exceeds work_mem. Rewrites all runtime occurrences to
correlated NOT EXISTS (or a literal comparison where the subquery was
0/1 rows). MySQL keeps the double-wrapped subquery in
delete-database-fields! since it cannot reference the DELETE target
table in a subquery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep is_audit predicate as NOT EXISTS in set-default-database-permissions!

Tests create audit DBs with random ids, so the fixed audit-db-id
constant is not equivalent to the is_audit = true subquery here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:24:50 -04:00
Romeo Van Snick
76e2e9ff2b Migrate enterprise plugin pages off withRouteProps (#78426) 2026-07-30 13:01:29 +10:00
Ngoc Khuat
0f1f3ce5ef Snowflake: stop over-escaping schema names in the dynamic-table probe (#78807)
Snowflake: don't over-escape schema names in the dynamic-table probe

The FK sync path pre-escaped schema/table names (`_` -> `\_`) before handing
them to `dynamic-table?`, which uses the schema as an identifier in
`IN SCHEMA <db>.<schema>`. `"RAW\_DATA"` is not a real schema, so Snowflake
threw for every table in any underscore-named schema and we logged a full
stack trace each time.

`dynamic-table?` now takes raw names and escapes only the `LIKE` argument,
where `_` is actually a wildcard. Escaping for the JDBC metadata call moved
to the call site. Also log the caught exception without the stack trace,
since it fires once per table.

Fixes #78541

Co-authored-by: da <paoliniluis@gmail.com>
2026-07-30 02:23:06 +00:00
Fraser
70ada33ee2 Move api plugin slots into api (#78747) 2026-07-30 10:40:18 +12:00
github-automation-metabase
40c64b039c Auto-update documentation for master (#78972)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-29 22:32:21 +00:00
Maksym Yakubych
0af1565243 Generate a fresh title for a forked Metabot conversation (#78379)
* Generate a fresh title for a forked conversation from its first new message

* Show "Forked conversation" instead of "New conversation" in fork header

* Rename first-valid-user-message to first-non-forked-user-message

* Align map value columns in metabot conversation schemas (PR review nits)
2026-07-29 19:16:33 -03:00
Maksym Yakubych
7e7ad719c0 feat: fork a Metabot conversation at an assistant message (#78261)
* feat: fork a Metabot conversation at an assistant message

* feat: surface Metabot conversation fork origin in analytics

Record forked_from_conversation_id on the conversation and
forked_from_message_id on each copied message, expose them through the
metabot-analytics API and audit views, and link a fork back to its
original thread.

* Update git_branch icon

* Show source conversation title in fork boundary and header

* Revert fork boundary to the attempt where the fork happened

Showing the fork boundary divider on every regenerated attempt was
misleading: the fork only happened on the inherited attempt, so the
divider matches that single message again.

* Refine fork boundary rendering and tests

* Exclude forked (copied) messages from v_metabot_messages

* Address PR review: unify message-action guard, extract ForkBoundary, assert fork toasts via UI

* Reuse createMockMetabotConversationDetail for the fork test fixture

* Replace fork props on Messages with a renderAfterMessage slot; move ForkBoundary to EE

* Drop the redundant UndoListing now that the metabot test setup mounts it

* Index and FK-constrain forked_from_conversation_id so deleting an origin nulls the link

* Split forked messages out of usage metrics with new_message_count

v_metabot_conversations gains new_message_count, which leaves out the
messages a fork copied in from its origin. message_count keeps counting
the whole thread, so the conversations list, the detail page and the
shipped model all report what the reader can actually see.

The usage stats charts aggregate the new column instead, which makes
SUM(new_message_count) reconcile with COUNT(*) FROM v_metabot_messages.
metabot-stats leaves copies out of :metabot-queries and :metabot-users
for the same reason — copies inherit role/ai_proxied and are stamped
with a fresh created_at, so a fork was reporting LLM calls that never
happened.

* Don't offer to fork while a turn is streaming

Only the streaming message's actions were hidden, so earlier assistant
messages kept their fork button mid-turn. Forking then swapped the
conversation out from under an in-flight stream whose reducers are keyed
by agentId alone, so the old turn's remaining deltas rendered inside the
fork.

Withholding onFork while isDoingScience is set is preferable to
cancelling the request: the user asked to branch off an earlier message,
not to stop the response, and loadConversation already avoids cancelling
so an aborted turn isn't recorded.

* Realign the analytics schema maps and fix a migration author

The fork columns are longer than the alignment column the surrounding
maps used. ConversationDetail had already drifted into two alignment
columns before this branch; both maps are now uniform.
2026-07-29 19:16:33 -03:00
Denis Berezin
b5c15c5002 UXW-4935 Hover effect for timeline event chips (#78865)
* UXW-4935 Hover effect for timeline event chips

* Remove vertical line from bar on hover

* Review fix

* Fix e2e tests
2026-07-30 01:14:28 +03:00
Fraser
b9fe183aa5 Invert markdown and email JSX rendering out of the formatting engine (#78880)
Extends the dynamic JSX formatter pattern from #77658 (which did links)
to markdown and email. formatValue and formatEmail now call a registered
renderer through the registry instead of importing ReactMarkdown/Mustache
and ExternalLink directly; the renderers live in ui.tsx and are wired up
at app boot by registerJsxFormatting (called from registerVisualizations).

This keeps the react-markdown/micromark/mustache stack and the mailto
ExternalLink out of the static-viz bundle, which imports the formatting
engine through shared echarts code but never registers the JSX renderers
(so jsx formatting degrades to plain text there, as it already did for
links). No behaviour change in the app.

Static-viz bundle: 3,450,947 -> 3,297,087 bytes (~150KB, 4.5%).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:07:37 +12:00
Jeff Bruemmer
edae5380bb docs - filter out read-only settings for config (#78970)
filter out read-only settings for config
2026-07-29 18:03:12 -04:00
Ben Grabow
bb4fa3fcf4 GHY-4215 Ignore invalidated-at when checking for early refresh (#78945)
Ignore invalidated-at when checking for early refresh
2026-07-29 13:32:45 -05:00
Timofey Kachalov
96e41d0d70 Hide data apps nav items if user does not have a token feature (#78946) 2026-07-29 22:22:28 +04:00
Egor Grushin
537113ca37 Gdgt 2911 theres no way to see current job progress on the job page (#78879)
show last job run transform statuses on the job page
2026-07-29 17:31:13 +01:00
Nemanja Glumac
891bb18a18 Add the sandbox-restricted-fields passthrough to metrics permissions (#78937)
metabase.metrics.core re-exports sandbox-restricted-fields from metabase.metrics.permissions, and metabase.metabot.tools.entity-details calls it through that API. The passthrough to metabase.permissions.metric was missing, so importing the re-export threw at load and broke every namespace downstream of metabase.metrics.core.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:25:14 -04:00
github-automation-metabase
794245fb42 Auto-update documentation for master (#78857)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-29 12:02:31 -04:00
dpsutton
66994aac46 Fix driver quarantine gate never failing the job (#78866) 2026-07-29 17:30:12 +02:00
Nemanja Glumac
d99fede8e2 Assert the skipped-dependent tally in the transform job failure email test (#78930)
Transform failure emails list root-cause failures by name and summarize dependents that never ran as a count, so the test asserts the tally instead of the dependent's name.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:29:49 +02:00
Denis Berezin
7eaf406eb1 Add Metric Dimensions (#76623)
* Add Metric Dimensions section

* [metrics] Field IDs are not unique! Multiple FKs to same table.

Fixes a few places where the field ID alone is assumed to be a unique
key for dimension mappings.

* [metrics] Add basic CRUD API for metric v2 dimensions

Fixes UXW-4631.
Fixes UXW-4633.

* [metrics] Add `/api/metric/$ID/dimension/set-default` endpoint

Fixes UXW-4632.

This sets the dimension of a Metric v2 with the specified
`dimension_id` (a body parameter) to be the `:default true`.
All other dimensions have `:default` removed, so there is at
most one default dimension for any metric.

* [metrics] Metric dimensions create dependencies on the tables

Fixes UXW-4636.

* [metrics] Serdes support for curated Metrics v2 dimensions

Fixes UXW-4634.

Previously `:dimensions` and `:dimension_mappings` were marked as
`:skip` on serdes since the dimensions were always synced on read.
Now that they're curated and only synced when blank, we need to
properly serialize dimensions and mappings.

* Adjust Metric dimensions UI + connect to api

* Add dimensions reorder with dnd

* Add metric dimensions usage in metric viewer

* Change Metric Viewer to group dimensions by type

* Implement shared dimensions logic, add e2e tests

* Use default dimension in Metric Viewer

* Align headings in panels / Make trash and add buttons the same height

* Move and simplify the Set as default button

* Make about page use the default curated dimension in the preview chart

* [dimensions] Bump `:card_schema` to 24; backfill `:dimensions` in old style

Fixes UXW-4808.

This gives pre-existing v2 Metrics the full `breakoutable-columns` as
their dimensions, rather than just the main group as is now the
default. That preserves the old behaviour for the dimensions of the
metrics.

* Respect default metric dimensions on dashboards / Add projectDimension lib function (#77910)

* Respect default metric dimensions on dashboards

* Consolidate metric dashboard E2E coverage

* Remove default time dimension from metric editor

* Implement a few design tweaks in Dimensions tab UI

* Use curated metric dimensions on dashboard metric card filters

* Add dimension picker to metric About page

* Auto-pick default dimensions for new metrics

* Additional cleanup to the About page dimension select element

* Expand all metrics in See all dimensions

* Remove table names from metric filters

* Don't show sub-headers in 'See all' dimension view in Metric Viewer

* Remove metric overview tab

* [UXW-4859] Handle empty curated dimension list and removed default dimension edge cases

* Fix default viz type when adding metric to dashboard

* Remove the 'See all' option on explorer page for single metric visualization

* [UXW-4863] Don't use custom display name as field reference in dimension detail column

* [backend] Fix persistence of upgraded metric dimensions

* [UXW-4880] Re-add Overview tab, show curated dimensions

* [UXW-4848] Make sure we show all shared dimensions in the Shared Dimensions view, if the underlying fields match

* [UXW-4771] Make pinned metric in collection respect default curated dimension

* Update e2e tests to account for PR updates [part 1]

* Seed explicitly joined metric dimensions

* Fix metric dimension serialization dependencies

* Fix/adjust backend unit tests

* Fix metric dimension lint issues

* Run metrics with saved card permissions

* Update e2e tests to account for PR updates [part 2]

* Update kondo ratchet for metric serialization test

* Update metric permissions e2e expectation [part 3]

* Fix BE sandbox unit test

* Prefix upgraded metric dimensions with connected table names (#78410)

[UXW-4896] Prefix upgraded metric dimension names

* Address FE issues from July 22 reviews

* use curated dimensions on series breakout list / fix dimension display name on breakout legend and dimension pill bar

* Optimize getMetricMappingOptions

* Adjust about page dimension select margin-bottom and dropdown positining

* Fix curated metric dimension selection across duplicate foreign key paths

* Improve logic to show correct dimension label in the CenterControls button

* Exclude orphaned dimensions from runtime metric metadata

* Address Ethan's comments / Clean up

* Use metadata field cache when checking user permission / Improve field validation when adding dimension

* Address latest reviews

* Update serialization baseline card_schema 23 -> 24

* Tighten docstrings and comments per review feedback

Remove caller references, ticket refs, and usage context from docstrings
and comments; describe contracts instead.

---------

Co-authored-by: Braden Shepherdson <braden@metabase.com>
Co-authored-by: Anderson <anderson@metabase.com>
Co-authored-by: Anderson <ands.alves.nunes@gmail.com>
2026-07-29 12:24:41 -03:00
Sloan Sparger
94408e998f [Metabot] Non-reasoning model loading state (#78855)
handle non-reasoning models better
2026-07-29 10:22:50 -05:00
Sloan Sparger
776e1a0c61 [Metabot] Reasoning UI (#78417)
* chain of thought ui

* self-review pass

* agent review

* height fix

* move reasoning narration gate out of shared snippet

* pr feedback
2026-07-29 10:22:50 -05:00
Sloan Sparger
207a7bdb06 [Metabot] Improved streamed reasoning metadata (#78416)
* stamp tool display titles and search results onto the stream

* self-review

* agent review

* avoid deprecated mbql-query macro

* expect search_results data event in integration test
2026-07-29 10:22:49 -05:00
Sloan Sparger
209d1ce72a [Metabot] Stream reasoning tokens (#78415)
* stream provider reasoning through the aisdk pipeline

* self-review

* agent review

* pr feedback
2026-07-29 10:22:49 -05:00
Andrei Chernykh
f670615026 chore: Convert the last auth JS file to TypeScript (#78863)
* Convert the last auth JS file to TypeScript

* cleanup test

---------

Co-authored-by: Uladzimir Havenchyk <uladzimir@metabase.com>
2026-07-29 08:02:56 -07:00
github-automation-metabase
e75f249d70 Stabilize SSO group-mapping delete flake by serializing setting round-trips (#78907)
* Stabilize SSO group-mapping delete flake by re-reading settled state

Selecting groups for a mapping optimistically patches the settings cache,
but the setting update also invalidates and refetches session properties.
A trailing stale refetch can revert the last-added group after the test's
per-selection checkbox assertion has already passed, leaving the mapping
with only one non-admin group. The delete then fires a single deleteGroup
request and cy.wait times out waiting for the second.

Re-load the auth settings page between adding groups and deleting the
mapping, and assert the row shows its full group summary before acting.
The fresh page load aborts in-flight refetches and reads a single settled
server state, so every mapped group is present when the delete fires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Serialize SSO group-mapping edits against their setting round-trips

Adding a group to a mapping PUTs the group-mappings setting (built from the
widget's current mappings cache) and invalidates session properties, which
triggers a background refetch. The widget optimistically patches the cache,
but a trailing stale refetch can revert the just-added group; the next
selection then builds its PUT from that stale set and overwrites the previous
group server-side (last-write-wins). The mapping ends up with fewer groups
than the test expects, so fewer deleteGroup requests fire and cy.wait times
out waiting for the second one.

Wait for each mapping mutation's PUT and its trailing session-properties
refetch to settle before the next interaction, in createMapping,
addGroupsToMapping (per group), and deleteMappingWithGroups, so every edit is
built from a fully committed round-trip. deleteGroup/clearGroup invalidate
only permissions-group, so updateSetting is the sole source of session
refetches and the waits stay aligned one-to-one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 17:00:59 +02:00
Chris Vandoodewaard
27f5824c42 Remove broken assertion from StrategyEditorForDatabases tests (#78916)
cleanup unused import
2026-07-29 10:47:45 -04:00
Chris Truter
2edc737aea Fix metadata enrichment permissions for linked tables (#78896)
* Fix metadata leaks for unreadable tables

* Close metric enrichment permission gaps

* Respect sandboxed metric dimensions
2026-07-29 16:32:01 +02:00
Anderson
6a045e90d6 Make sorting consistent in small dashboard tables when including NULL text values (#78701)
* Fix dashboard sorting for small text datasets

* Preserve automatic sorting for non-text columns
2026-07-29 11:25:21 -03:00
Chris Truter
113f912ad2 Make the library index's authorization contract explicit (#78897) 2026-07-29 13:53:44 +00:00
Sébastien
e1fb469d57 Preserve passed size in SDK question loading and error states (#78906) 2026-07-29 15:41:47 +02:00
Nicolò Pretto
f8bdb80c12 refactor eajs bridge (#78570)
* fix: scope Metabase host marker to main app

* refactor eajs bridge
2026-07-29 12:53:20 +00:00
Chris Vandoodewaard
024244e68e add LTS Docker tag to releases (#77885)
* update getExtraTagsForVersion to append -lts if isLts is set

* add ability to check if a version is an LTS version

* set islts

* update function to determine if we should tag with lts

* add condition for multiple LTS releases so we only tag the latest

* fix actionlint errors

* update lts tag to include major version

* await result of getExtraTagsForVersion

* fix type

* fix merge conflict, don't throw error
2026-07-29 08:26:53 -04:00
Chris Vandoodewaard
71b85f3710 account for the case where something changes in github/workflows and it isn't a workflow (#78904) 2026-07-29 08:26:34 -04:00
Nemanja Glumac
3fd695cba6 Fix broken backend test assertions (#78902) 2026-07-29 12:03:45 +00:00
Oleh K
95e367f533 Remove color-mix from theme color definitions (#78665)
* GDGT-2851 Remove color-mix from theme color definitions

color-mix can only be resolved by a browser, so any theme color using it
breaks static viz, which renders on the server (see UXW-4690, where the
gauge failed on background_page-tertiary-inverse).

Per design's proposal, introduce a new orion-95 stop and use solid scale
values for the page backgrounds:

- dark: page primary/secondary/tertiary -> orion 95/100/110
- light: page primary/secondary/tertiary inverse -> orion 95/100/110

The remaining mixes are collapsed to their literal equivalents:
shadow-default to a plain rgba, and tooltip-background-focused to the
next stop down from tooltip-background.

The mixes that derive from whitelabel colors (core-brand, core-filter,
core-summarize) are left alone, since their values aren't known ahead of
time. A unit test now guards against reintroducing color-mix anywhere
else in the theme definitions.

* Update Loki Snapshots

* Remove var() references and non-hsla literals from theme definitions

Static viz feeds theme colors straight into Color(), which only parses
literals, so a var() throws there exactly like a color-mix does — even
when it points at a perfectly ordinary color. Collapse the remaining
var() references to the underlying base color, and normalize the few
hex and bare-keyword values to hsla so every theme color is a literal.

The guard test drops the transitive resolver it no longer needs and just
asserts that every non-whitelabel color is an hsla literal.

* Fix regression in pivot table

* Empty commit to trigger CI

* Manually update pivot table loki snapshot

* Update e2e colour assertions for the new background tokens

Dark background_page-primary moved from orion-100 to the new orion-95,
so anything asserting rgb(7, 23, 34) for it now expects rgb(12, 28, 39).

The dark mode body assertion was matching color(srgb 0.0204 0.06792
0.0996) — Chrome's computed form for the old color-mix on
background_page-secondary. That token is a plain hsla now, so Chrome
reports it as rgb(7, 23, 34).

* Fix e2e

* Fix e2e

* Revert changes in assertDarkMode

* Fix oss e2e spec

* Revert SDK theming assertions to the deprecated background token

The SDK theme presets resolve colours through the first entry of
SDK_TO_MAIN_APP_COLORS_MAPPING, and for `background` that is the
deprecated `background-primary`, which still points at orion-100.
`background_page-primary` sits at index 1 and never gets read, so
table-root is unaffected by the move to orion-95.

* Address review feedback on the colour theme changes

Move the gold and upsell-gem literals into base-colors, since each was
repeated across both theme files. Drop the intermediate staticColors
binding in the guard test in favour of a second filter, and drop the
`?? {}` on theme.colors, which is a required field.

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-29 13:06:47 +02:00
Nicolò Pretto
307c7c9e54 Fix SDK test concurrency collision (#78898) 2026-07-29 12:33:40 +02:00
github-automation-metabase
29cefea9f5 Fix click-behavior multi-stage e2e flake: settle mapping list between selections (#78602)
* Fix click-behavior multi-stage e2e flake: settle mapping list between selections

The "navigating to questions with filters applied in every stage" test sets 11
click-target mappings sequentially, using index-based selectors (.first(),
.last(), .eq(2)) against the unset-click-mappings list. That list re-renders as
each mapping moves from the unset to the set section, but there was no barrier
between steps, so an index-based lookup could resolve mid-re-render and land on
the wrong target — yielding a stable-but-wrong filter set (a duplicated
"Product → Title" filter replacing the dropped "Reviews - Product → Reviewer"
one, which is exactly the assertion that fails).

Extract source selection into selectClickMappingSource(), which asserts the
source picker popover has closed before returning, so every subsequent
index-based lookup runs against a settled list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix click-mapping settle barrier to assert popover closed correctly

The previous settle barrier used `H.popover().should("not.exist")`, but the
`H.popover()` helper internally chains `.filter(":visible").should("be.visible")`,
so the assertion is self-contradictory: it requires a visible popover to exist
and then asserts it does not, failing deterministically the moment the source
selection closes the popover.

Assert the popover is gone with `cy.get(H.POPOVER_ELEMENT).should("not.exist")`
— the idiomatic pattern used across the e2e suite — so the mapping list still
settles between index-based selections without the contradictory visibility check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Scope click-mapping settle barrier to the visible popover

The unset-click-mappings list re-renders as each mapping moves unset->set.
A bare `cy.get(POPOVER_ELEMENT).should("not.exist")` can never settle in the
click-behavior sidebar: it keeps ~60 mantine popover/combobox dropdowns
mounted-but-hidden, so they are continuously found in the DOM. Filter to
`:visible` so the barrier only waits for the open source picker to close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 12:11:38 +02:00
Nicolò Pretto
d140a6f451 Drop conventional commit PR title reminder (#78888) 2026-07-29 11:34:24 +02:00
Timofey Kachalov
b909e55cdb Split guest-embed.cy.spec.ts into describe blocks for OSS and EE (#78830) 2026-07-29 13:34:21 +04:00
Nicola Mometto
5005f99c38 avoid cascade list of errors reported in emails (#78674)
* don't cascade all errors on a failed job run

* test
2026-07-29 10:10:38 +01:00
Nicola Mometto
2979a1f2e6 fix checkpoint field id serdes (#78666)
* reg test

* fix: route through export/import-field-fk

* fix: add field serdes dependency

* fix
2026-07-29 10:10:25 +01:00
Timofey Kachalov
692f047b92 Move Data Apps under data-apps-preview token feature (#78800) 2026-07-29 13:05:03 +04:00
Sébastien
d8f7ae6a91 Add CLI usage analytics (#78216) 2026-07-29 08:47:33 +02:00
Romeo Van Snick
bfbedff78a Stop passing query objects in navigation descriptors (#78796)
* Export queryToSearch and searchToQuery from metabase/router

* Serialize the query with queryToSearch at the navigation call sites

* Read search, not query, off a navigation descriptor

* Drop query from LocationDescriptorObject
2026-07-29 15:41:55 +10:00
Romeo Van Snick
24354fd2f4 Warn on PRs that grow the main app's initial bundle (#78652)
* Warn on PRs that grow the main app's initial bundle

* Fix actionlint findings in the touched workflows
2026-07-29 12:20:41 +10:00
Romeo Van Snick
3488d15252 Retire the state.routing slice (#78189)
* Thread QB popState off the routing slice

* Thread dashboard edit-mode off the routing slice

* Thread Metabot profile selection off the routing slice

* Thread document Metabot context off the routing slice

* Migrate component location readers to useLocation

* Retire the state.routing slice
2026-07-29 12:12:24 +10:00
Andrei Chernykh
17782894af Convert getExpandedCollectionsById to TypeScript and fix the stale ExpandedCollection type (#77538)
* Convert getExpandedCollectionsById to TypeScript and fix the stale ExpandedCollection type

* Type fake collection ids as literals instead of casting to CollectionId, dropping the String() workaround
2026-07-28 17:20:15 -07:00
Alexander Polyankin
e312b48757 Audit log statements (#78841)
* Audit log statements: no stack traces, no queries/user input, no pretty-printing

- Replace throwable args to log macros with (ex-message e) so ex-data and
  stack traces never reach the logs (~450 sites)
- Remove SQL/MBQL queries, prepared-statement params, HTTP request/response
  bodies, PII, and user-authored names from log output (IDs kept)
- Replace u/pprint-to-str in log calls with pr-str, or drop the value
- Silence sqlglot parser warnings in the GraalPy bootstrap (they quote raw
  chunks of customer SQL to stderr)
- Rewrite leftover functional colors (hsl/hsla/rgb/rgba) to hex in static-viz
  SVG output so Batik never errors (and stack-dumps) on them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use logf variants for exception messages; restore serdes --full-stacktrace

- Convert print-style `(log/warn "msg:" (ex-message e))` calls to
  `(log/warnf "msg: %s" (ex-message e))`
- Serdes import/export logs the full stack trace again when the
  --full-stacktrace CLI option / full_stacktrace API param is passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update swap-clauses tests for clause-free warning messages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update log-content test assertions for the new logging policy

Log calls no longer attach throwables (entries have :e nil) and append
ex-message text to the message instead; body/secret leak tests now assert
the payload never appears in log output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review comments

- rethrow-api-error!: update docstrings — response body is never logged,
  it travels on the thrown ex-data
- expand-macros: log the segment filter-clause count once instead of an
  identical trace line per clause
- settings cache: log the whole SQLException message chain (the old
  print-sql-exception-chain walked .getNextException; ex-message alone
  dropped the chained causes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:32:29 -04:00
Fraser
d6b078baa5 Remove settings slice (#75983) 2026-07-29 10:25:25 +12:00
Uladzimir Havenchyk
a1909a240b chore: convert column settings to typescript (#70302) 2026-07-28 22:23:10 +00:00
Andrei Chernykh
8699c85e9b chore: Convert the last audit_app JS files to TypeScript (#78745)
* Convert the last audit_app JS files to TypeScript

* Address review comments
2026-07-28 15:17:08 -07:00
Maksym Yakubych
e4705cad9b Move flaky usage-auditing e2e coverage into unit tests (BOT-1909) (#78838) 2026-07-28 19:02:10 -03:00
appleby
2a503cffab Metabot Enable Opus 5 (#78695)
Add Claude Opus 5 to the anthropic openrouter and bedrock whitelists

- anthropic   claude-opus-5
- openrouter  anthropic/claude-opus-5
- bedrock     anthropic.claude-opus-5
2026-07-28 14:28:43 -07:00
da
5854e3e83f Edits on docs about how to run python transforms in self hosted deployments (#78098)
Add docs about how to run python transforms in self hosted deployments
2026-07-28 16:51:09 -04:00
Michael Eby
44e66e883b docs: add invite to view a dashboard or question (#78167)
* docs: add invite to view a dashboard or question

* Edits from Jeff
2026-07-28 16:50:57 -04:00
Egor Grushin
0374e1244c GDGT-2768 GDGT-2769 remove deprecated TableColumnFingerprintInfo and TableColumnInfo (#78519) 2026-07-28 21:24:19 +01:00
Alexander Polyankin
ae68432f30 Health check per engine (#78813) 2026-07-28 13:27:52 -04:00
Andrei Chernykh
0a9e61f44f Scope the invite group picker to the groups with access to the shared item (#77898)
* Scope the invite group picker to the groups with access to the shared item

* Imply All Users membership only for invites with a target, and mock the scoped groups endpoint in share menu specs

* Show all groups when inviting to view and mark which ones can access the item

* Make the invite picker's group section labels visible and tidy up UserForm's membership mapping

* Generalize the group picker into ordered labeled sections with group a11y roles, keeping invite-target semantics in UserForm

* Use the compact Alert size for the invite picker's no-access warning
2026-07-28 09:59:57 -07:00
Alexander Polyankin
7c58c0539f Perms - t2/reducible-query + per-row realize + fold (#78811) 2026-07-28 12:27:35 -04:00
technomancy
3133454b99 Suppress api-key-missing exception when failing to generate sample question (#78733)
Suppress api-key-missing exception when failing to generate sample question.

Not having an API key is the default situation and should not result
in an error being logged.
2026-07-28 18:13:49 +02:00
Ben Grabow
b6083e9e20 Temp pin mysql latest (#78812)
* Add MySQL 9.0 app db test suite

* Add MySQL 9.0 app db test suite, disable latest
2026-07-28 18:13:31 +02:00
Stas Gavrylov
f3f72896b7 GDGT-2910: Rename Model caching log to Model persistence log (#78787)
* GDGT-2910: Rename Model caching log to Model persistence log

* GDGT-2910: Renamed route and components to ModelPersistence*
2026-07-28 19:08:39 +03:00
github-automation-metabase
89a870bbae Auto-update documentation for master (#78731)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-28 12:05:25 -04:00
Timofey Kachalov
f923c031c2 Enhance DevToolBar (#78539) 2026-07-28 19:48:50 +04:00
Chris Truter
836d5568ac Support library-retrieval using pgvector on the appdb (#78682)
* Let library retrieval use pgvector on the app db

The tables were bare-named, so the drop-and-rebuild in ensure-tables! would have
resolved against the application database's default schema. That is what kept
the feature dedicated-only.

Table names now come from the pgvector mode, the way semantic search picks
between its two index-metadata configs: bare for a dedicated store, so no
existing index moves, and qualified by a library_retrieval schema when sharing
the app db. Its own schema, not semantic search's, whose migration drops every
table in it.

Raw SQL interpolating a qualified name into "%s" would read it as one
identifier, so those sites go through quote-table.

The sync task's scheduling gate stays boot-safe: a dedicated URL, or the license
plus a Postgres app db, decided without resolving the mode. available? checks
the license before the store for the same reason.

* Cover the app-db round trip end to end

Opt-in like the semantic-search one, and picked up by the same appdb-mode CI
job. Asserts the tables land in library_retrieval, the application schema is
untouched, retrieval returns the indexed entity, and a semantic-search wipe
leaves the library index alone -- the reason the schema is separate.

* Check our own schema before reporting the store ready

Semantic search's predicate answers for semantic_search. A role that can use
that schema but cannot create library_retrieval passed it, and then failed every
reconcile inside ensure-tables!. Probe for our schema too: present, or creatable
in a rolled-back transaction, cached because available? runs on every write
nudge and search.

* Gate the round trip on the capability the feature checks

Gating on semantic search's check let the harness pass where a role that can use
semantic_search but not create library_retrieval fails in production.

* Check schema privileges, not schema existence

A role with USAGE but not CREATE passed the existence check and then failed
creating tables. Ask Postgres for both privileges instead, and only fall back to
the rolled-back CREATE SCHEMA when the schema isn't there at all.

The whole probe is inside the try now: the existence query wasn't, so a
transient database failure could throw out of request-entity-sync!, which the
write hooks call fire-and-forget.

* Put the dedicated index in the schema too

A dedicated store kept the index in the default schema, which is exactly what
semantic search's migration wipes -- so enabling or migrating semantic search
dropped the library index and silently re-embedded the whole library. It fires
whenever the stored schema version trails the code, not just on first init.

Both modes now use the library_retrieval schema, so default-tables and
app-db-tables collapse into one. An index built before the schema existed is
moved into it with ALTER TABLE ... SET SCHEMA, keeping its rows; the move runs
before the IF NOT EXISTS creates, which would otherwise leave an empty table
beside it. Creating the schema now needs CREATE on the dedicated database, so a
failure says so.

* Drop only semantic search's own tables in a dedicated store

A dedicated store's default schema is shared -- library retrieval keeps its
index there until it moves -- so wiping every table in it could drop that index
before entity retrieval had a chance to adopt it. Both run as background jobs,
so nothing ordered them.

Scoped to the index_/dlq_/repair_ prefixes, and only in dedicated mode: the
module's own schema in app-db mode holds nothing else, so a reset there is still
free to clear leftovers. The app-db sentinel refusal still reads every table in
scope, since an app db is recognized by tables we would never drop.

* Match the names semantic search generates, not their prefixes

A cohabitant in a shared dedicated store is free to call something
index_history, which the prefix check would have dropped. Match the control
tables exactly, index tables through index-table-name? -- the same contract
orphan cleanup uses -- and DLQ and repair tables by their generated shapes.

Also covers the adoption path end to end: a legacy unqualified index with a row
in it, moved by ensure-tables! and still holding the row afterwards. Asserted
there rather than after the reconcile, which GCs documents the library no longer
implies.

* Stop the availability tests depending on the app db

available?-gates-test left the real schema probe live while stubbing only
the pgvector mode. The probe is Postgres-only SQL, so it threw and read as
unusable on the H2 and MySQL runs, and the case failed there.

The round trip's legacy table declares a vector(4) column before
ensure-tables! runs, and the support check verifies CREATE EXTENSION in a
transaction it rolls back, so nothing has installed the type by then.

* Answer review on the dedicated-store reset

Rename ours? to semantic-search-table?, and point the two docstring
references at namespaces the reader can resolve.

Cover the case where a legacy bare table and a qualified one both exist:
adoption has to leave them alone, since SET SCHEMA onto an occupied name
errors and would abort the whole ensure-tables! transaction.

* Latch the app-db schema check once it passes

The check catches everything and returns false, so on a plain TTL a single
transient app-db failure took retrieval offline for five minutes: available?
went false, search returned nothing and the write nudge no-oped.

A confirmed yes now sticks for the life of the process. Only a no keeps
re-checking, so a privilege granted while we run is still picked up.
2026-07-28 17:24:48 +02:00
lbrdnk
0a5aa515e6 Add eval-run triggers (#77960)
* Stub the evals trigger

* use ref arg

* dummy to trigger build

* Clean up

* Use appropriate token

* Simplify pr message

* Use .evals_ref for default ref

* Comment cleanup

* Adjust ref

* Use evals branch

* Use general automation token

* readme for .evals_branch

* Use main of evals for label trigger

* Revert "dummy to trigger build"

This reverts commit e6502c6ff076a47d94eb829b516fc45b37cd7680.

* whitespace

* Address review remarks

* Use ai benchmark naming so we can trigger from pr

* Address review comments

* Remove now redundant branch pinning

* Review remarks
2026-07-28 17:12:51 +02:00
Anderson
c48e0783ee Fix double vertical scrollbar in admin settings when the deprecated driver notice is visible (#78687)
Fix double vertical scrollbar in admin when the driver deprecation notice is shown
2026-07-28 11:38:06 -03:00
Anderson
7e2f3f6f67 Keep browse table tooltips anchored while hover actions disappear (#78622)
Fix browse card tooltip teleport
2026-07-28 11:37:55 -03:00
Nick Fitzpatrick
d42f057d2e add no cross db join message to Data Picker (#78673) 2026-07-28 11:33:43 -03:00
Chris Truter
426d1b617c Stop sending MariaDB-only timeout syntax to MySQL 26 (#78802) 2026-07-28 16:05:37 +02:00
Riley Thompson
e1c2730e9b Remove unneeded bigquery parent in plugin yaml (#78732)
remove unneeded bigquery parent in plugin yaml
2026-07-28 13:30:44 +00:00
Kevin Kelleher
6e9d52021c Fix broken duplication of inline dashboard filters (#78549)
* properly duplicate inline parameters

* add tests

* some comments for clarity
2026-07-28 13:00:42 +00:00
Nick Fitzpatrick
60eae45519 Slack PDFs - send using channels query param for DMs (#78263) 2026-07-28 08:10:40 -03:00
Chris Vandoodewaard
efb763bef8 use graphQL to collect issues and prs for a given milestone (#78496) 2026-07-28 07:09:44 -04:00
Chris Vandoodewaard
463870fb3c set backport pr to draft if there are conflicts (#78591) 2026-07-28 07:09:27 -04:00
Nicola Mometto
04407a3c35 support lookback windows for incremental transforms (#78212)
* feat: add lookback

* validate lookback

* feat: apply lookback

* tests

* fix

* fix test

* feat: only support lookback on temporal checkpoints

* update test

* FE

wip

* add :type/Time test

* add test with watermark row removed

* refactor to avoid double get-in

* only support date/datetime

* tests

* fix: support coerced cols

* add more tests

* refactor

* more tests

* test for coerced instant

* use utils in FE too

* chorec

* further validate

* use lib utils

* use strict comparison

* allow clearing lookback

* handle race
2026-07-28 09:58:56 +00:00
Chris Truter
d3528c7502 Stop the H2 app-db fixture from baking in your encryption key (#78696) 2026-07-28 17:32:24 +08:00
Jakub Chodorowicz
2c7b1c9608 cleanup init code (#78675) 2026-07-28 11:22:38 +02:00
Uladzimir Havenchyk
ee7bd843ce chore: migrate registerVisualization to typescript (#70306)
* chore: migrate registerVisualization to typescript

* chore: drop 'as unknown as Visualization' cast in custom viz plugin loading

applyDefaultVisualizationProps now takes the plain component and returns
the typed Visualization via Object.assign. The SDK's date_style format
option is widened to string | null to match the ColumnSettings value the
host actually passes to checkRenderable.
2026-07-28 10:41:37 +02:00
Nemanja Glumac
14681e7dcf Cover sql filter's error message behavior with the lower level tests (#78692)
* Unit test the native editor run button's unmapped-tag tooltip

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Unit test the Save button's unmapped-tag tooltip and disabled state

Covers both dimension and temporal-unit template tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Cover temporal-unit tags in the canRun template tag validation test

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the unmapped field filter e2e test in favour of unit coverage

Every assertion it made is now covered by NativeQueryEditorRunButton and
ViewTitleHeader unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:27:08 +12:00
Fraser
56eaca30d6 Remove dead modules where the folders no longer exist (#78754) 2026-07-28 16:15:12 +12:00
Ben Grabow
fc9441add0 Prevent serving overly stale query cache results (#78689)
* Prevent serving overly stale results

* Refresh cache results slightly before they expire
2026-07-27 22:27:28 -05:00
Romeo Van Snick
a3f7259763 Repoint the facade location hooks at react-router v7 and free Palette from route props (#78669)
* Read useLocation and useParams straight from react-router v7

* Re-export react-router's Outlet instead of shimming it through context

* Disable the command palette by path instead of route props

* Drop the now-unused route props plumbing
2026-07-28 12:57:54 +10:00
dpsutton
0ed95e05f6 Unshared appdb connections + detached audit cluster lock (fix first-boot stall) (#78276)
* Add mdb/with-unshared-connection: appdb connections exempt from ambient reuse

A connection holding fragile long-lived state -- a streaming result set
(postgres portal/cursor), or FOR UPDATE row locks -- must not be picked
up by ambient connection reuse: a borrower that inherits it via
*current-connectable* and commits destroys that state. This is the
failure mode behind the first-boot hang in #78238, the revert (#77048)
of appdb streaming (#76645), and "cursor/portal does not exist" errors
when field updates run mid-sync-stream.

Built on toucan2's new unshared-connection! (camsaul/toucan2#212): the
connection is used when passed explicitly -- (t2/reducible-query conn
...), (t2/with-transaction [t conn] ...) -- but never bound as
*current-connectable*, so toucan calls that resolve their connection
ambiently run (and commit) on other pooled connections.

mdb/with-unshared-connection is checkout -> mark -> body -> close; in
every other respect the connection is ordinary JDBC, configured by the
body (e.g. .setAutoCommit false to stream). The pg test reproduces the
exact #77048 failure on an unmarked control connection (portal killed by
a nested commit mid-reduction) and shows the unshared connection
streaming to completion.

deps.edn temporarily pins toucan2 to the PR sha via :git/sha; a
follow-up commit will switch back to :mvn/version once a toucan2
release includes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

* Use released toucan2 1.0.572 for unshared-connection support

camsaul/toucan2#212 merged and released; drop the :git/sha pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

* Hold the audit DB cluster lock on a detached connection (fixes boot stall)

Since #76551 the audit install/load/sync pipeline runs inside a cluster
lock whose SELECT FOR UPDATE ties lock lifetime to transaction lifetime:
the whole pipeline became one long transaction on the lock's connection.
The serdes load's ~3,600 per-entity nested transactions piled up as
savepoints and subtransaction XIDs, and every row lock was held until a
single commit at the end -- stalling first boot for 10-25+ minutes and
blocking concurrent work like config-file database provisioning (#78238).

with-cluster-lock now supports :detached? -- the lock rows are held on a
dedicated unshared connection (toucan2 unshared-connection!) while the
body runs on ordinary pooled connections, committing incrementally in
its own short transactions. Locks release at body completion (commit) or
throw (pool check-in rollback). Re-acquiring a lock this thread already
holds detached is a no-op in both detached and transactional form;
upgrading a detached :share hold to :exclusive throws instead of
self-deadlocking; :retry-transient? is rejected (a re-run would
double-apply incrementally-committed work).

The audit pipeline opts in: per-entity serdes isolation (#74412) is
restored, and a crash mid-pipeline is repaired by the next boot (the
checksum only advances on completion; reconcile runs every boot), which
is what makes incremental commit safe here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

* Address review: confine detached retry to acquisition; repair dialect-sync window

Review findings (galdre), all majors addressed:

- Major 1: retry/with-retry wrapped the whole detached body, so a
  retryable-looking error thrown by the body (e.g. a nested cluster lock
  timing out inside the serdes load) re-ran an incrementally-committed
  body and then surfaced as "Failed to obtain cluster lock", which
  ensure-audit-db-installed! swallows as benign contention. An acquired?
  volatile now confines both the retry predicate and the acquisition-
  failure wrapper to the pre-acquisition phase in detached mode; body
  errors propagate raw with intact cause chains.
- Major 2: a failed or killed engine-changed dialect sync was never
  repaired once the content checksum had advanced. New durable setting
  audit-db-dialect-sync-pending: set before the sync attempt, cleared on
  success, a third sync trigger on every boot.
- Major 3: the mysql/h2 engine-flip visibility window under detached
  mode is documented as a KNOWN WINDOW at adjust-audit-db-to-source!
  (restores pre-#76551 semantics; overlay-based resolution is follow-up).
- Majors 4-6: reviewer's contract tests adopted (throw-path lock
  release, check-in rollback, behavioral pin on :detached?, crash-mid-
  load self-heal, plus the two Major-1 reproductions); public docstrings
  updated to describe all three lock modes and the upgrade throw.

Minors: unified cross-mode reentrancy registry with eager share->
exclusive upgrade validation (fail-fast in any detached interleaving;
legal same-transaction upgrade preserved); h2 read->write upgrade throws
instead of hanging; lock-row INSERT gets a query timeout (bounds the
N-node cold-start pile-up); install-database! and each reconcile group
are single transactions; unshared checkout goes through *application-db*
so the restore-time read-lock gate applies; registry conveyance and
explicit-tx-on-unshared-connection caveats documented; dual kondo
lint-as entries; test hygiene (finally cleanup, nil ambient contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

* Right-size the engine-flip visibility comment

The committed, briefly-visible engine flip is this pipeline's
longstanding behavior; only #76551's single-transaction era hid it for
one release. A neutral note fits better than a warning block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

* Tighten comments to constraints; drop review-context narration

Comments now state what the code can't show (crash windows, why raw
JDBC, why the marker precedes the sync) and drop justification written
for reviewers: issue-number history, caller name-drops, repeated
tellings of the retry-confinement story, and cost reassurances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

* Make the detached lock a bespoke entry point instead of a with-cluster-lock mode

Reviewer pressure on the :detached? option had generalized
with-cluster-lock into a two-mode API whose cross-mode interleavings all
needed guarding: a held-locks registry, mode-upgrade validation, an h2
upgrade guard, and an acquired? volatile threading retry confinement
through the shared machinery — all protecting call patterns nothing in
the tree performs.

with-detached-cluster-lock is now its own function: exclusive-only,
single lock, documented non-reentrant. Structure replaces machinery —
the body sits outside the acquisition retry, so body errors are never
retried nor rewrapped as acquisition failure (no volatile), and
same-name re-entry throws via a two-line dynamic-set guard instead of a
registry (nested *different* locks, which the audit body does take,
work unchanged). with-cluster-lock returns to its previous shape; the
lock-row INSERT keeps its new query timeout, which bounds the
multi-node cold-start pile-up in both modes. Error shape is unchanged,
so audit-lock-contention? still classifies correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:54:55 -07:00
Alexander Polyankin
84a9434f08 Compact in-memory format for the per-request data-permissions cache (#78723)
* Compact in-memory format for the per-request data-permissions cache

The request-scoped cache in metabase.permissions.models.data-permissions
previously stored raw data_permissions rows as full Toucan instances, keyed
{user-id {perm-type {db-id [row ...]}}}. On instances with thousands of
databases and users in many groups a single prime-db-cache call could
materialize over a million instances (hundreds of MB retained per request,
plus the matching GC churn).

Store a compact entry per (perm-type, db-id) instead:

    {:groups {group-id perm-value}                           ; db-level rows
     :tables {table-id [schema-name {group-id perm-value}]}} ; table-level rows

and intern the group->value maps at load time: on big instances they are
typically identical across most databases, so the retained size becomes
proportional to the number of distinct permission profiles instead of
groups * databases.

Also fetch only the columns the checks actually consume (group_id, perm_type,
db_id, table_id, schema_name, perm_value) instead of p.*, and fix an O(n^2)
already-cached filter in prime-db-cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* small fix

* Use metabase.util.performance helpers in the cache hot path

perf/concat + perf/mapv in entry-group-value-pairs. Deliberately NOT
perf/update-vals for the interning pass: it skips replacing values that
are = to the old ones, which would undo the interning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop perf helpers from entry-group-value-pairs

perf/concat over an already-built vector is just (into a b), and reduce-kv
iterates the map faster than mapv over entry seqs. Plain into + reduce-kv,
one less require.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address code-review findings on the compact permissions cache

- Duplicate data_permissions rows (no unique constraint on
  group/type/db/table) no longer silently last-win: cache slots promote to
  a set when duplicate rows disagree, so every value reaches the consumers'
  coalescing logic exactly as raw rows did. Regression test added.
- schema_name is stored per table-level row ({:schema .. :value ..})
  instead of first-row-wins per table, since it is denormalized and rows
  for one table can disagree.
- The interner now lives in the *permissions-for-user* atom, so value
  sharing spans every prime in the request (db-at-a-time priming
  previously got a fresh pool per call), and it reuses
  metabase.util.memoize/fast-interner instead of a bespoke pool.
- rows->cache-entry returns nil for empty rows so cached and non-cached
  get-permissions branches agree; the non-cached branch skips interning.
- schema-permission-for-user and most-permissive-database-permission-for-user
  now go through entry-group-value-pairs instead of hand-rolled traversals.
- Dropped the impossible-nil guards in prime-db-cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix

* Address CodeRabbit nitpicks

- gid->slot -> group-id->slot (no abbreviations)
- native-download-permission-for-user reuses most-restrictive-per-group
  instead of re-implementing the grouping inline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename local intern -> interner (Eastwood local-shadows-var)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Explain the slot rationale in conj-slot/slot-seq docstrings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* simplify slots

* Document interning with a worked example in the cache docstrings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014V8QbHjoEkJ8eZLG13sYUe

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: dan sutton <dan@dpsutton.com>
2026-07-27 19:29:20 -04:00
Sloan Sparger
b2651a0af3 [Metabot] fix conversation scroll calc issue (#78642) 2026-07-27 17:30:29 -05:00
Tyler Plude
3c9fd3afe9 [BOT-1901] Fix missing type info on metric & measure refs (#78520)
* annotate metric and measure ref types

* fix tests

* pr comments
2026-07-27 15:09:46 -07:00
Alexander Polyankin
1ec45cf46f Remove the workspaces feature (#78617) 2026-07-27 17:27:57 -04:00
Chris Vandoodewaard
43497b47bd disable cross version tests temporarily (#78724)
* disable workflow

* add a helpful note
2026-07-27 16:33:22 -04:00
Andrei Chernykh
f275c0735d Restyle the sharing popover with Copy link and Embed buttons above the actions list (#78584)
* Restyle the sharing popover with Copy link and Embed buttons above the actions list

* Reserve the copy button width with hidden pseudo-elements instead of inline styles
2026-07-27 13:12:22 -07:00
Chris Vandoodewaard
cfcf9fc18b don't tag author on Slack since Github doesn't populate author login info (#78569)
call Github if login unset, also fix lookup function
2026-07-27 15:46:46 -04:00
Timur Mashara
f07aac4b88 New storage upsell 2 (#78162)
* Revert "🤖 backported "Revert 'GRO-554 update storage upsell (#76052)'" (#78041)"

This reverts commit b067da1824.

* introduce gsheets and csv panel states / new hooks for attached dwh

* update storage setup error view + conditions to show / setup timeout

* remove skipPollingIfUnfocused, increase polling timeout
2026-07-27 19:16:38 +00:00
Maz Ameli
694e3466a6 Swap placement of dashboard Subscriptions action with Auto-refresh action (#78063)
* swap subscriptions action  with auto-refresh action

* hide Subscriptions for non-admins if slack and email aren't set up

* small adjustments

* e2e adjustments

* more e2e adjustments

---------

Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
2026-07-27 16:15:23 -03:00
Timothy S. Dean
1ce2f7a546 Bump netty (#78641) 2026-07-27 13:01:34 -06:00
technomancy
660ea06251 MBQL5ize snowflake and bigquery drivers. (#76769)
* MBQL5ize snowflake and bigquery drivers.

* bigquery fixes

* fix test-bigquery-log

---------

Co-authored-by: Riley Thompson <rileythompson99@gmail.com>
Co-authored-by: Riley Thompson <riley@metabase.com>
2026-07-27 14:47:33 -04:00
Chris Truter
a317fbf1b8 Fix legacy semantic search startup validation (#78700) 2026-07-27 18:15:46 +00:00
metamben
00637eef75 Make the experiment setting available (#78698)
* Make the experiment setting available
* Enable analytics on the JVM
2026-07-27 18:08:36 +00:00
Andrei Chernykh
166175677e Convert the legacy chart legend components to TypeScript function components (#78258)
* Convert the legacy chart legend components to TypeScript function components

* Share the legend prop types and extract a typed getChartLayout helper with tests
2026-07-27 10:00:19 -07:00
Eric Normand
eeb40ed109 Don't drop entities whose parameter values-source Card was deleted (#78611)
Deleting a Database hard-deletes its Cards with raw SQL, but nothing
rewrites the `parameters` JSON on Cards/Dashboards that used one of
those Cards as a dropdown source. The parameter keeps a
`values_source_config.card_id` pointing at a row that is gone.

On export that made `*export-fk*` throw `:target-not-found`, which
`log-and-extract-one` swallows as a skip — silently dropping the whole
Card or Dashboard from the archive.

Export each parameter on its own now, and when one carries a card
values-source that can no longer be resolved, emit it without
`:values_source_type`/`:values_source_config`. That is the same shape
the app writes when a source Card is archived, so the parameter comes
back as a plain dropdown over its connected fields.

A source Card that merely isn't part of the export is unaffected —
`*export-fk*` only throws when the row is absent, so those still fail
dependency validation as before.

Fixes GHY-3259
2026-07-27 11:57:04 -05:00
Chris Truter
187f4df352 Give the raw core_user insert an entity_id (#78691)
The helper inserts straight into the table to bypass the model's transforms,
which bypasses the hook that fills in entity_id too. #78452 made that column NOT
NULL, so every App DB Tests job has failed on it since.
2026-07-27 12:13:27 -04:00
Riley Thompson
7a73a7a68a Move sqlserver and oracle to mbql5 (#77414)
* sqlserver mbql5

* oracle mbql5

* fix sqlserver tests

* fix test failures

* fix sqlserver test errors

* clean up

* kondo, test fix

* small changes
2026-07-27 11:13:00 -04:00
John Swanson
2c82f4332c Eagerly load tenant auth-provider; fail tenant SSO logins loudly (UXW-4898) (#78492)
* Eagerly load tenant auth-provider; fail tenant SSO logins loudly (UXW-4898)

Root cause: metabase-enterprise.tenants.auth-provider — the login! method
that validates a login's tenant claim and stamps :tenant_id onto the user
data — was never eagerly loaded. The JWT/SAML providers only `derive` from
its dispatch keyword, which does not load the namespace, so on a freshly
started instance the tenant step was silently absent from the login!
method chain until unrelated traffic (e.g. /api/user/current hydrating
user attributes) lazily loaded it. A JWT provisioning login in that window
created the user with tenant_id = nil even though the token carried a
valid @tenant, the tenant-group assignment failed and was swallowed, and
the login returned 200 — permanently wedging the account (every later
login 403s with "Cannot add tenant claim to internal user").

Fix, plus defense in depth so no future tenant-drop can wedge an account:

- Add metabase-enterprise.tenants.init requiring the auth-provider, wired
  into metabase-enterprise.core.init, so the tenant login step is always
  registered at startup.
- create-user! throws instead of silently dropping :tenant_id when the
  sso-user-fields field list would strip a tenant assignment stamped by
  the upstream tenant flow.
- sync-group-memberships*! no longer swallows real tenant/group mismatch
  errors (bad pairs whose :group-is-tenant? is non-nil); the login fails
  loudly instead. Nonexistent groups keep log-and-continue. This also
  covers EMB-2118 (JWT omitting @tenant with a mapped tenant group).
- The login! provisioning chain (tenant creation, user create/update,
  group sync) runs in one transaction, so a failed login leaves no
  half-provisioned user row behind.

* Fix cljfmt formatting in jwt_test.clj
2026-07-27 05:50:16 -07:00
Romeo Van Snick
54a2ef930b Migrate data studio pages off withRouteProps (#78423) 2026-07-27 21:58:10 +10:00
Chris Truter
4e307d23bd Raise CI timeout for driver tests hitting their cap under load (#78537) 2026-07-27 13:52:38 +02:00
Romeo Van Snick
ac2f1ead36 Adopt v7 navigation semantics, drop the v3 parity guards (#78653)
* Stop the location-syncing call sites from replacing the url they just read

* Cover the documents /new to /new warning with a unit test

* Adopt v7 navigation semantics, drop the v3 parity guards
2026-07-27 20:41:25 +10:00
Romeo Van Snick
357e2739bd Stop the router from importing metabase/redux (#78527)
The v7 provider mirrored every location into state.routing by calling
useDispatch itself, which is what kept metabase/router bound to the store.

createLocationMirror takes a plain dispatch function instead, so the router
publishes locations without knowing redux exists. app.js and the test harness
supply it as RouterProvider's onLocationChange prop. V7ReduxBridge is renamed
V7RouterBridge now that nothing in it is redux-specific.

metabase/router now passes a basic-tier boundaries trial with zero violations.
2026-07-27 20:07:40 +10:00
Sébastien
0e9c0eab62 Fix SDK Storybook chunk loading broken after #75985 (#78664) 2026-07-27 10:58:22 +02:00
Egor Grushin
9f9ee277cc style(sdk-bundle): addressing SDK error comments (#78548)
* addressing SDK error comments

* wrap SdkError stories with PublicComponentWrapper
2026-07-27 09:51:43 +01:00
Kamil Mielnik
9896c4aee5 GDGT-2564 Webhook destination cannot be saved from the UI (#78334) 2026-07-27 13:49:23 +07:00
Timofey Kachalov
76d6c139d4 Add Vite Data App Dev App e2e tests (#78454) 2026-07-27 09:09:00 +04:00
Romeo Van Snick
03b8f5cefc Move route guards out of metabase/router (#78526)
Guards are authorization policy, not routing primitives. They were the largest
source of shared-tier imports inside metabase/router (redux, plugins, and four
selector modules), blocking the module from dropping to a lower tier.

The generic factory and the app-wide auth/setup guards move to a new shared
metabase/route-guards module. The monitor and data-studio guards move into their
own features, which lets getMonitorRoutes and getDataStudioRoutes stop taking
them as parameters.

Router violations under a basic-tier trial go from 13 to 3; the rest are the
redux location mirror in the v7 provider.
2026-07-27 10:36:48 +10:00
Romeo Van Snick
158902de0f Migrate core route pages off withRouteProps (#78427) 2026-07-27 10:15:57 +10:00
Romeo Van Snick
5a4ca22140 Migrate admin pages off withRouteProps (#78425) 2026-07-27 08:20:32 +10:00
Ben Grabow
93f9a2fa2c Reset undecryptable rows to an empty value instead of aborting the entire decryption process (#78383) 2026-07-24 22:03:46 +00:00
github-automation-metabase
eac0d3fcc3 Auto-update documentation for master (#78631)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-24 21:31:55 +00:00
Nathan Voxland
39c1100a2f Improve queue handling when there is no quartz available (#78145) 2026-07-24 16:27:15 -05:00
Nemanja Glumac
a77b7d2ed8 Simplify settings error e2e tests (#78614)
* Add GeoJSON error mocks and cover map load failures in unit tests

`setupGeoJSONEndpoint` could only produce a valid FeatureCollection, so the
error branches of the custom map flow had no unit coverage.

- `setupMalformedGeoJSONEndpoint` returns a 200 with a payload the client-side
  validator in `metabase/api/geojson` rejects, since those checks run in
  `transformResponse` and need the payload to reach the client.
- `setupGeoJSONEndpointWithError` returns a non-2xx with a bare string body,
  matching what `api-exception-response` sends for an `ex-info` carrying only
  a status code.
- `createMockProjectedGeoJSONFeatureCollection` supplies projected coordinates
  so every bounds corner falls outside lat/lng range.

Covers the invalid-location, missing-features, and out-of-bounds errors in
CustomGeoJSONWidget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop map GeoJSON error e2e tests in favour of unit coverage

All three asserted error strings are now covered more cheaply. The two
backend-owned messages are pinned by metabase.geojson.api-test against mocked
HTTP, and the modal rendering them is covered by CustomGeoJSONWidget. The two
client-side validation messages come from transformResponse in
metabase/api/geojson, which the e2e tests reached with the backend fully
stubbed or bypassed.

Two of them also drove real outbound requests from CI — one to www.metabase.com
and one to a file on the public repo's master branch.

projected.geojson had no other consumer; the equivalent payload is now built by
createMockProjectedGeoJSONFeatureCollection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop admin settings e2e tests that duplicate existing unit coverage

Each of these asserts the same condition as a unit test that already exists,
not merely an adjacent one:

- the HTTPS redirect check failure message is asserted in HttpsOnlyWidget
  ("should display an error message if the site is not accessible via https")
- the billing info fetch failure is asserted in LicenseAndBillingSettings
  ("renders error for billing info for store managed billing and info request
  fails")
- the absent license input for hosted instances is asserted in
  LicenseAndBillingSettings ("does not render an input when token has hosting
  feature enabled")

mockBillingTokenFeatures and its feature-key constants had no remaining
callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:50:45 +02:00
Nemanja Glumac
107df96fbd Harden the glob in cross-version test scripts (#77182) 2026-07-24 22:15:20 +02:00
Jeff Bruemmer
0e9bec40f0 docs - transform updates (#78376) 2026-07-24 19:48:44 +00:00
Nick Fitzpatrick
d6285436bf Uxw 4246 bulk actions in the library (#76558)
* Bulk Actions in Library

* adjust e2e

* pr feedback

* final CI adjustments
2026-07-24 16:20:25 -03:00
Chris Truter
0d2b723687 Recover invalid semantic search HNSW indexes (#78428) 2026-07-24 20:45:17 +02:00
technomancy
25b687f674 Make snowflake-with-dbname-in-details-gets-synced-test tolerant of failed cleanup (#78578)
Make snowflake-with-dbname-in-details-gets-synced-test tolerant of failed cleanup.
2026-07-24 18:30:23 +00:00
appleby
1e6ea2b1f4 Remove :doc false from user configurable llm defsettings (#78586)
The following `:visibility :internal` settings remain `:doc false`
- ai-service-base-url
- llm-proxy-base-url
- llm-anthropic-api-version
2026-07-24 18:25:40 +00:00
Eric Normand
65f07f2d11 Persist disabling a collection from remote sync (GHY-4189) (#78493)
* Persist disabling a collection from remote sync (GHY-4189)

Disabling a collection deleted its RemoteSyncObject rows outright, so the
removal left no dirty state to push and the collection's files stayed on the
remote. A later pull then re-imported the collection (its YAML still carried
is_remote_synced: true), re-enabling it.

Mark the collection's and its contents' RemoteSyncObject rows as "removed"
instead — a pending deletion the next export pushes to the remote — while
dropping rows still in "create" (never pushed, so nothing to delete remotely).

* Don't delete contents when a collection is re-synced before the push

Disabling a collection records a pending removal on its contents'
RemoteSyncObject rows. Re-enabling it re-tracked only the collection's own row,
so the contents stayed marked for deletion and the next export removed their
files from the remote.

Clear the recorded removal when a collection is re-synced before that removal
was pushed. Restore to "update" rather than "synced": edits made while the
collection was un-synced are not tracked, so the entity must be re-serialized
for the remote to be guaranteed to match local.

* Stop core-test leaking RemoteSyncObject rows between tests

Disabling a collection now marks its RemoteSyncObject rows rather than deleting
them, so rows seeded by a test outlive it and leak dirty state into every later
test. The old delete-on-disable behavior had been doing this cleanup by
accident.

Reset the table around each test with the clean-object fixture.

* Assert the pull succeeds in the disable round-trip test

The test discarded impl/import!'s result, so an errored pull would leave the
collection un-synced for the wrong reason and the following assertion would
pass vacuously.

* Re-check eligibility at write time so a pending removal isn't resurrected

An event handler computes eligibility from its event payload, then writes the
RemoteSyncObject row much later. A disable committing in between left the
handler writing a tracked status over the "removed" status the disable had
recorded, so the entity stayed on the remote after being un-synced.

Re-check eligibility against current DB state before letting a tracked status
overwrite a pending removal, and let the removal stand when it no longer holds.

* Row-lock the sync entry so the un-sync race has no remaining window

Re-checking eligibility at write time narrowed the race but did not close it: a
disable could still commit between the re-check and the update, and the losing
write would take effect.

Row-lock the entry for the transaction so a tracking write and a concurrent
un-sync settle in a fixed order instead of losing one of the two: whichever
locks first commits, and the other observes that result — the un-sync re-marking
the row, or the eligibility re-check seeing the entity leave the synced set.

The race test drives the disable on its own thread and unparks the handler
without waiting for it, since the parked handler holds the row lock the disable
needs. Its assertion holds for either order, so the choreography only biases
which one is exercised.

* Address review: honest race coverage, insert-path guard, tighter asserts

- Split the concurrency test into two, each parking the handler at the point
  that exercises one ordering: disable-first (guarded by the write-time
  eligibility re-check) and handler-first (guarded by the row lock, which the
  disable now provably blocks on). The prior single test parked on the plain
  existing-entry read, so it only ever exercised the re-check ordering while its
  comment claimed lock contention.
- Re-check eligibility in the insert branch too: a stale tracked-status event
  that finds no row inserts a fresh one, a phantom the row lock cannot cover.
  This narrows, but cannot fully close, that window.
- Round-trip pull test asserts = :success rather than not= :error, so a
  :conflict result can no longer slip through.
- restore-removed-rsos! docstring no longer claims it only clears removals from
  record-removed-rsos!; it clears any 'removed' row in the subtree.

* Re-track never-pushed contents when a collection is re-synced

Disabling a collection drops its contents' never-pushed 'create' RemoteSyncObject
rows (the remote never had them, so nothing to delete there). Re-enabling only
restored the rows that were marked 'removed', so a card added-but-never-pushed
before the disable was left with no row — and the next incremental export, seeing
no dirty row for it, silently omitted it.

On re-enable, insert a 'create' row for every eligible content entity in the
re-enabled subtree that has none, so the next export pushes it. The
RemoteSyncObject table only records pending changes, so already-synced content is
legitimately untracked; re-marking it 'create' re-serializes it harmlessly (a
'create' onto its own path is a no-op).
2026-07-24 12:58:10 -05:00
Ben Grabow
1c2e735ab0 GHY-4103 Repair stale template tags on serdes import (#77897) 2026-07-24 11:15:35 -05:00
Mahatthana (Kelvin) Nomsawadi
bb6977debd feat: simplify SDK release process and checklist maintenance (#77537)
* scaffold: two-step SDK release process (EMB-1518)

Replaces the hardcoded version/dist-tag computation in
release-embedding-sdk.yml with a new sdkRelease metadata block in
package.template.json (distTag, tagAsLatest), plus a new
sdk-version-bump-pr.yml workflow that bumps the version via `npm version`
and opens a PR labeled sdk-release-trigger for review. Merging that PR
triggers the release workflow directly.

Unverified: scaffolded per EMB-1518 ahead of Nicolo/Roman's review, since
this can only really be exercised by triggering a real SDK release. See
.claude/kelvin/2026-07-09-emb-1518-simplify-sdk-release-process-and-checklist-maintenance/
for the plan and open questions (particularly around distTag semantics for
stable/patch/minor releases).

* fix: correct distTag comment, <major>-stable is real (EMB-1518)

npm dist-tag ls @metabase/embedding-sdk-react confirms 51-stable
through 62-stable are already live, actively-used tags - not a new
concept as the prior comment assumed. Resolves the one open question
this scaffold couldn't settle from the proposal text alone.

* fix: address review cycle 1 findings (EMB-1518)

Fixes release-breaking and correctness issues found in review cycle 1
of the SDK release process scaffold:

- beta release_type now defaults to <major>-beta dist-tag instead of
  the literal (non-existent) "beta" tag
- validate branch/release_type combinations that the old
  getNextSdkVersion used to hard-error on (master requires alpha,
  release branches reject alpha)
- pull_request trigger on release-embedding-sdk.yml now scoped to
  package.template.json via a paths filter, and the concurrency group
  no longer cancels an in-flight release on unrelated PR closures
- readme-PR follow-up steps now skip cleanly when the readme was
  already up to date, instead of failing and firing a false alert
- tagAsLatest now always resets to false rather than carrying forward
  a prior true, and the actual value is shown in the summary/PR body
- version-bump branch name includes the run id to avoid collisions on
  retry
- renamed a step whose name didn't match what it does

See the "Review cycle 1 (fable)" section and the new "Fixes applied
for review cycle 1" subsection in the plan file for full detail on
each finding and its disposition.

* fix: address review cycle 2 findings + simplify version bump (EMB-1518)

Cycle-2 findings: add a preminor release_type so master's next-major
alpha bump no longer needs a hand-made PR (#9); make the Slack
failure notification independent of an artifact upload that may
never have run (#10); guard against two open version-bump PRs racing
each other (#11); stop interpolating the free-text branch input
directly into shell scripts (#12).

Also replaces the npm-version rename dance (rename to package.json,
bump, rename back, plus a .bak copy) with direct calls to the semver
package the bump logic already wraps, writing the result via jq
instead - no functional change, removes the dead .bak file (#13).

* fix: address review cycle 3 findings + rename resolve-release job (EMB-1518)

Fixes findings #14-#16 from the third fable review cycle:
- #14: the failure Slack alert's messageId interpolation was unquoted,
  rendering a JS syntax error whenever send-slack-notification-start was
  skipped (the exact scenario cycle-2's fix #10 was meant to cover). Now
  quoted, and falls back to sendSlackMessage (new top-level message)
  instead of sendSlackReply when there's no thread to reply to.
- #15: finished the cycle-2 injection-hardening pass by routing the two
  remaining inline branch-value usages (workflow-summary's heredoc,
  publish-npm's gh pr create --base) through env: instead of ${{ }}.
- #16: added a semver validity pre-check before the bump step, so an old
  release branch's 0.N.x placeholder version fails with a clear ::error::
  instead of a silent exit 1 from the semver CLI.

#17 (minor's version-collision caveat on old release branches) is left as
an open question for Nicolo/Roman rather than removed - documented with a
comment and Judgment call #6. #18 (merge-commit-sha race) is deferred -
it fails loudly rather than silently, lower priority than the others.

Also renames the resolve-release job to prepare-release throughout
release-embedding-sdk.yml, per explicit request (not a review finding).

See the plan file's "Fixes applied for review cycle 3" section for the
full verification detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2Va4ryWuZbQvmUt2N85ec

* feat: auto-compute tagAsLatest from vars.LATEST_VERSION (EMB-1518)

Resolves Judgment call #2: the SDK version-bump workflow previously
always defaulted tagAsLatest to false because there was no reliable
way to know if a branch is the current "latest" major. The repo
already exposes vars.LATEST_VERSION (major version of the most recent
gold/stable release) for exactly this, so patch/minor bumps now set
tagAsLatest=true when MAJOR_VERSION matches it, false otherwise
(including when the variable is unset). alpha/beta/preminor stay
false. The PR review gate still allows overriding the computed value
before merging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2Va4ryWuZbQvmUt2N85ec

* refactor: use github.ref_name instead of inputs.branch (EMB-1518)

github.ref_name is already set by GitHub's "Use workflow from" ref
picker on every workflow_dispatch run, so the custom inputs.branch
free-text field was duplicating a selection that was already made -
and could silently diverge from it. Switch every functional read of
inputs.branch to github.ref_name in both release workflows; keep the
branch input field itself, but make it explicitly non-functional
(required: false, no default, description points at the ref picker).

type: choice was considered and rejected again here: it would close
the typo risk but can't accept an unregistered branch, breaking
one-off custom-branch releases without editing the workflow file per
release - exactly the maintenance burden EMB-1518 removes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2Va4ryWuZbQvmUt2N85ec

* fix: remove dead readme-update mechanism (EMB-1518)

update_readme's two sed patterns match zero occurrences in the current
README (restructured by 15f7d3d20e, no version-pinned content remains),
so publish-npm's readme-follow-up-PR step has been a silent no-op on
every release, not just re-runs. Deleted the entire dead block (7 steps,
the env vars and pull-requests:write permission it alone used) and the
now-unused update_readme function.

* fix: drop minor release_type option (EMB-1518)

`semver -i minor` on a release branch jumps to the next major's
version line, colliding with that major's own branch. `patch` already
covers graduation and `preminor` already covers the legitimate
next-major jump (scoped to master). Drop `minor` outright rather than
add compensating validation - resolves review-cycle-3 finding #17 /
Judgment call #6.

* fix: add explicit reviewer request for embedding team on bump PR (EMB-1518)

CODEOWNERS is deliberately stripped on release branches (a repo-wide
practice to cut auto-backport review noise, unrelated to this
workflow), so the bump PR's "code owner review is automatic" comment
was false there - it got zero reviewers requested on release
branches. EMB-1000 documented the identical gap in the old system's
follow-up PR. Fix targets the metabase/embedding team (not a named
individual - the old system's hardcoded reviewer has since left).

* fix: restrict release types by branch, add custom releases (EMB-1518)

A one-off branch could publish as the stable release. Validation only
guarded master and release-x.*, so `patch` on e.g. sdk-data-apps stripped
the prerelease id (0.62.5-data-apps.0 -> 0.62.5), computed the
<major>-stable dist-tag, and - when the major matched vars.LATEST_VERSION
- set tagAsLatest, shipping a feature branch to npm as the stable release.
Only check-git-tag stood in the way, and only if that version had already
shipped.

Each branch class now gets only the release types that make sense for it:
master takes alpha/preminor, release branches take beta/patch, any other
branch takes custom. tagAsLatest is additionally gated on a release-x.*
branch, so nothing published from master or a one-off branch can take the
npm `latest` tag.

The custom type keeps one-off branches (esbuild, data-apps) releasable
under that restriction: prerelease_id is set on the first cut and becomes
both the version's prerelease id and the <major>-<id> dist-tag, matching
the shape already live on npm (56-esbuild, 63-data-apps). Later bumps
leave it empty and semver reuses the id from the committed version.

Also drops the dead release_utils.bash (zero functions, zero callers),
replaces both free-text dispatch inputs with single-option choices that
cannot be typed into, points the release workflow's form at the bump PR,
and removes the scaffold banner comments.

* fix: read vars.LATEST_MAJOR_VERSION, not LATEST_VERSION (EMB-1518)

The variable is LATEST_MAJOR_VERSION - it is what release.yml already
reads to decide Docker's `latest` tag, and what the release checklist has
a github admin bump each cycle. `vars.LATEST_VERSION` does not exist, so
tagAsLatest would have computed false on every bump, including at gold,
without any error - the fallback for an unset variable is false.

* refactor: make the release concurrency group readable (EMB-1518)

Same three cases as before, but each arm now names what it produces
(sdk-release-<branch> for a dispatch or a merged bump PR, sdk-release-noop-<run id>
for a closed PR this workflow ignores), and the comment above maps them in
the order the expression tries them.

The noop arm is not decoration: concurrency is evaluated before any job's
`if:` runs, so a closed PR that prepare-release skips would still enter the
release group without it. cancel-in-progress stays false so a release is
never interrupted mid-publish.

Groups are prefixed rather than bare because concurrency groups are
repo-wide, not per-workflow.

* refactor: build the release scripts once, in their own job (EMB-1518)

The Slack jobs each got at release/dist/index.cjs a different way:
send-slack-notification-start checked out and built it (then uploaded it as
an artifact), send-slack-notification-failure built its own second copy, and
send-slack-notification-successful downloaded the artifact. The duplicate
build existed because the failure job has to fire even when the metadata read
fails - in which case the start job never ran, so its artifact never existed.

A build-release-scripts job now builds it once and uploads it, and all three
Slack jobs download it. The job hangs off prepare-release only, so the
artifact is there no matter how early the release fails - which removes the
reason the second build existed.

* fix: quote the Slack messageId in the success notification (EMB-1518)

A Slack ts is a string. Interpolated bare it becomes a JS number literal and
drops trailing zeros - 1783602196.214000 renders as 1783602196.214, and the
reply targets a thread that does not exist. Pre-existing on master; the
failure job already quoted it, so this makes the two consistent.

* refactor: resolve the release branch in one expression (EMB-1518)

The env: BRANCH indirection was injection hardening from when the value was
the free-text `inputs.branch`. It is github.ref_name now - a real branch
name, same as github.event.pull_request.base.ref, which the next line already
inlined - so it guarded nothing and made the step asymmetric.

base.ref is empty on a dispatch, so `base.ref || ref_name` picks the right one
without branching on github.event_name.

* refactor: drop the duplicate-bump-PR pre-flight (EMB-1518)

It guarded a case the design already handles safely. Two open bump PRs
against the same branch both compute from the branch's committed version, so
both propose the same next version: the first to merge publishes and tags it,
and the second dies at check-git-tag with "Tag ... already exists" (npm would
reject the republish too). Nothing is published twice, and the error says
exactly what happened.

Getting there needs someone to dispatch the bump twice and leave the first PR
open, with both PRs sitting labelled in the list. That is not worth an API
call and 16 lines on every bump.

* chore: trim the bump step's comments (EMB-1518)

* refactor: drop the semver validity pre-check (EMB-1518)

Every supported release branch (57 through 63) already carries a valid
semver, and the new process never writes a `0.N.x` placeholder - gold is a
patch bump (0.63.0-beta.3 -> 0.63.0). The placeholder only ever existed
transiently under the old checklist, which rewrote it back to a concrete
version after each release.

Also saves an npx invocation on every bump.

* chore: spell out PRERELEASE_ID in the bump workflow (EMB-1518)

* refactor: spell out the semver call in each release type (EMB-1518)

alpha and beta shared an arm that passed --preid="$RELEASE_TYPE", and patch
passed -i "$RELEASE_TYPE" - you had to know the variable's value to know what
command ran. Each arm now names its own call.

The release_type description now leads with the phase (before gold, at gold,
after gold), which is what the person dispatching actually knows. It also
corrects preminor: master moves to the next major *after gold*, not at branch
cut - the 62 cycle did it in the after-gold checklist (#75487, 0.62.0-alpha ->
0.63.0-alpha), and 61 before it (#74279).

* docs: stop calling the bump PR a review gate (EMB-1518)

Nothing blocks on approval: the bump PR is not auto-merged but it is not
gated on a reviewer either, and workflow_dispatch can publish with no PR at
all. The computed distTag/tagAsLatest are defaults made visible and editable
- whoever dispatched the bump has already decided.

* docs: note EMB-2095 (auto-merge the bump PR) in the workflow (EMB-1518)

* docs: tighten the tagAsLatest comment (EMB-1518)

* refactor: collapse the tagAsLatest case into one condition (EMB-1518)

* docs: say what the sdkRelease write does (EMB-1518)

* refactor: drop the label-creation step (EMB-1518)

The sdk-release-trigger label is repo-level, so it only ever needed creating
once - it now exists. Re-running `gh label create ... || true` on every bump
swallowed every error (including auth) to no purpose.

* fix: fail on a leftover bump branch instead of side-stepping it (EMB-1518)

The run-id suffix made every retry a fresh branch, so an abandoned bump left
a branch on origin forever and the name was unpredictable. The branch is now
named after the version, and an existing one is an error: an earlier bump to
the same version was never merged, which is worth knowing before opening a
second PR alongside it.

* refactor: drop the no-backport label step (EMB-1518)

Backporting is opt-in - auto-backport.yml only fires on a `backport` label -
and backport-reminder.yml only demands a decision label when a PR touches
docs/**. The bump PR touches one JSON file, so no-backport guarded nothing.

Keeps the reviewer rationale, moved next to the --reviewer flag it explains.

* feat: show the target branch and computed values in the bump PR body (EMB-1518)

The PR body did not say which branch it targeted, which is the first thing
you want to know when a release PR shows up. Branch, release type, version,
dist-tag and tagAsLatest are now a table at the top.

* fix: pin semver and validate prerelease_id (EMB-1518)

npx --yes semver resolved whatever the registry served at dispatch time, on
a job that installs no dependencies - so release-critical version math could
change under us while package.json already pins ^7.6.3. Pinned to semver@7.

prerelease_id was passed to semver unchecked. An id like `DataApps` bumped
fine and then died three steps later in the dist-tag read-back, whose regex
only matches [a-z][a-z0-9-]*, with an error blaming the computed version
instead of the input. It is now rejected up front, with a message naming the
rule.

* fix: take semver's version from package.json (EMB-1518)

Pinning the workflow to semver@7 fixed the drift but created a second
version to keep in step. Read the range the repo already pins instead
(devDependencies.semver, currently ^7.6.3) and hand it to npx, so bumping
semver in package.json is the only place it happens.

* refactor: rename bump workflow input to read_first (EMB-1518)

* feat: reject prerelease_id on non-custom release types (EMB-1518)

* refactor: drop the preminor bump-only warning from the PR body (EMB-1518)

* refactor: drop the duplicate rerun wording in tag-exists error (EMB-1518)

* fix: reject reserved alpha/beta as a custom prerelease_id (EMB-1518)

* refactor: tidy and clarify comments in the SDK bump workflow (EMB-1518)

* fix: satisfy actionlint in the SDK release workflow (EMB-1518)

* feat: extract embedding SDK release logic into a tested helper (EMB-1518)

* refactor: remove dead embedding SDK helpers from version-helpers (EMB-1518)

* fix: drop trailing blank line in the SDK bump workflow (EMB-1518)

* refactor: run the SDK version bump from a tested helper, not inline bash (EMB-1518)

* docs: note the env block guards github-script against injection (EMB-1518)

* refactor: drop the unused LATEST_MAJOR_VERSION fallback in the bump script (EMB-1518)

* feat: show the target branch in the bump PR title (EMB-1518)

* docs: drop the manual-edit invitation from the bump PR body (EMB-1518)

* refactor: tidy the SDK release helper and its tests (EMB-1518)

* refactor: read the SDK release metadata via the tested helper (EMB-1518)

* refactor: env-route the branch name in the bump PR title (EMB-1518)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_0156DAaukfiU6xceXd3brbZN

* refactor: simplify SDK release concurrency group (EMB-1518)

Collapse the three-case concurrency expression to one per-branch group, and
switch every multi-item needs array to a block sequence.

The dropped noop group guarded a compound case: GitHub keeps one pending run
per group and cancels any earlier one, so a closed PR touching the template
could evict a release waiting to start. That needs two same-branch releases
in flight at once, so we take the simpler expression. The clean fix is
queue: max, which actionlint cannot parse yet.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:34:19 +01:00
Riley Thompson
a08f7ff918 Show confirmation modal when uploading CSV to model with duplicates (#77636)
* show warning modal

* address pr comments
2026-07-24 13:37:47 +00:00
github-automation-metabase
fdace56d22 Deflake transform revision history spec (#78272)
* Deflake transform revision history spec

The "view and revert transform revisions" test changed the transform
name twice, each firing a "Transform name updated" success toast that
lives for the default 5s undo timeout. Unlike every other update flow in
this spec, these toasts were never dismissed. On a fast runner the test
reached the failed-revert error toast within that window, so a lingering
success toast coexisted with it and H.undoToast() (a single-element
findByTestId on [data-testid="toast-undo"]) failed with "Found multiple
elements".

Dismiss each success toast right after it appears, matching the existing
convention used elsewhere in this file, so exactly one toast is present
when the error toast is asserted.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
2026-07-24 14:55:37 +02:00
github-automation-metabase
8b52336966 Auto-update documentation for master (#78517)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-24 07:54:47 -04:00
Sébastien
0795d60b21 Extract a shared AdminDataTable for admin list tables (sortable + paginated) (#78136) 2026-07-24 12:35:17 +02:00
Timofey Kachalov
5fe045c2ab Add an endpoint that outputs Data App Dev diagnostic reports for an agent (#78358) 2026-07-24 13:23:00 +04:00
Chris Truter
1ba3a7c050 Add semantic search health checks (#78299)
* Add semantic search health checks

* Split HNSW recovery from semantic health

* Use HoneySQL literals for repair queries

* Use dynamic redefs in semantic search test
2026-07-24 10:24:15 +02:00
Ngoc Khuat
36fa06a4bd Serialize card last_used_at so imports don't reset it (#78338) 2026-07-24 06:44:37 +00:00
Chris Truter
a323e1c883 Add NLQ retrieval health checks (#78298) 2026-07-24 08:31:00 +02:00
Egor Grushin
a9731f6e65 style(sdk-bundle): Alert component redesign (#78260)
* redesign Alert, remove deprecated Alert component

* fix styling so far

* update alert stories

* additional changes

* small fix

* refactor Alert config

* address PR's comments

* chores

* Update Loki Snapshots

* addressing the designing feedback in places

* addressing the designing feedback in the Alert itself

* add loki tests

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-24 08:08:28 +02:00
Romeo Van Snick
a3e82fb537 Replace TreeTable getRowHref with a renderRowLink render prop (#78523) 2026-07-24 14:36:03 +10:00
Romeo Van Snick
14a65406f1 Fix dashcard #scrollTo auto-scroll (#78522)
* Auto-scroll to a dashcard whenever it becomes the scrollTo target, and keep the scroll until the grid settles

* Drop a stray brace from the auto-scroll spec's first visit URL

* Simplify the auto-scroll correction to a fixed frame window
2026-07-24 14:15:46 +10:00
Fraser
1591e74409 Fix module stats counting (#78184) 2026-07-24 15:53:41 +12:00
Romeo Van Snick
dee31666b9 Migrate transforms pages off withRouteProps (#78422)
* Migrate monitor tools off withRouteProps

* Migrate metric pages off withRouteProps

* Migrate JobInfoApp off withRouteProps

* Migrate transforms pages off withRouteProps
2026-07-24 13:51:17 +10:00
Romeo Van Snick
bd7af8355e Migrate monitor and metric pages off withRouteProps (#78421)
* Migrate monitor tools off withRouteProps

* Migrate metric pages off withRouteProps

* Migrate JobInfoApp off withRouteProps
2026-07-24 12:48:12 +10:00
Romeo Van Snick
24220fe191 Delete the react-router v3 engine (#78305)
* Delete the react-router v3 engine

* Sever the react-router package dependency

* Give the SDK data-app router its own browser history on v7

* Rename the react-router-v7 alias to react-router

* Rename getDataAppHistory to getRawBrowserHistory, drop dead getCurrentHistory
2026-07-24 08:56:17 +10:00
dpsutton
f35f7450a6 Scope migration existence preconditions to their table (~46s off every full pg migration) (#78403)
Scope existence preconditions to their table; lint against name-only checks

A foreignKeyConstraintExists precondition with only foreignKeyName (or
indexExists with only indexName) forces Liquibase to snapshot the entire
schema to answer the check -- ~2s per precondition on a large postgres
appdb. 27 such preconditions (24 FK, 3 index; 21 in one v59 workspace
block) added ~46s to every full migration: 50.2s of total changeset time,
of which 24 changesets cost 45.9s and the other 1,182 cost 3.7s.

Add the scoping key to all 27. Measured on a fresh postgres migration:
total changeset time drops 50.2s -> 5.1s, with zero changesets over 1s.

Editing released changesets' preconditions is safe: Liquibase checksums
cover the changes, not preConditions. Verified two ways -- ChangeSet
.generateCheckSum is byte-identical before/after for all 27 (v9
checksums), and a clone of an already-migrated appdb re-validates
cleanly against the edited files.

lint-migrations-file: generalize require-primary-key-exists-has-table-name
into require-table-scoped-existence-preconditions, covering
primaryKeyExists/indexExists/uniqueConstraintExists (tableName) and
foreignKeyConstraintExists (foreignKeyTableName), walking the full
precondition tree in both its flat-map and list forms.


Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:11:37 -05:00
Maksym Yakubych
089b82338c Make AI auditing Conversations table horizontally scrollable (#78378) 2026-07-23 16:06:15 -05:00
Oleksandr Yakushev
f63af2b18c [perf] Implement containsKey for SnakeHatingMap (#78097) 2026-07-24 00:04:13 +03:00
Riley Thompson
a37f29e737 Match remapped fields with ID or name based fields refs (#78246)
* apply fix

* add tests

* keep key structure the same

* update test

* improve fix

* fix bad field id refs test

* add sandboxing test
2026-07-23 21:01:26 +00:00
Chris Truter
8112036b95 Add shared search index health metrics (#78297)
* Add embedder circuit breaker and recovery

* Add embedder breaker edge-case tests

* Avoid noisy circuit-open embedding errors

* Add shared search index health metrics
2026-07-23 22:57:15 +02:00
Ethan McCue
dec394cf5e Allow config files to be named with a .yaml extension if no .yml file is found. (#77823)
* Fall back to .yaml if .yml not found

Update docs to mention `.yaml` support

* Fall back to .yaml if .yml not found

Update docs to mention `.yaml` support

* Update file.clj

* Fix tests

* Address feedback
2026-07-23 20:30:32 +00:00
Chris Truter
020b02932d Add embedder circuit breaker and recovery (#78296)
* Add embedder circuit breaker and recovery

* Add embedder breaker edge-case tests

* Avoid noisy circuit-open embedding errors
2026-07-23 22:04:03 +02:00
Alexander Polyankin
9fc9f53a1f entity_id is always present; drop the identity-hash machinery (#78452) 2026-07-23 19:29:42 +00:00
technomancy
9ccac4efed Drop field usage table (#78408)
* Drop field_usage table as it is no longer used.

* Stop saving preprocessed_query in metadata since it's not used any more.

This used to be used for saving off field usage, but that hasn't been
a thing for a very long time.
2026-07-23 18:46:00 +00:00
Ngoc Khuat
7b60d66387 Limit body-only dashboard subscription results (#78431) 2026-07-23 17:37:14 +00:00
AlessioLaiso
3360008053 Make model-illustration.svg work well in both light and dark mode (#68072)
* Make model-illustration.svg work well in both light and dark mode

* whitelabel svg

---------

Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
2026-07-23 14:36:37 -03:00
Andrei Chernykh
f24e4bed1f Migrate PinMap to a typed function component and extract a pure getPoints (#78067)
* Migrate PinMap to a typed function component and extract a pure getPoints

* Type the pin metric as nullable, derive MapSettings from VisualizationSettings, and add createMockVisualizationProps

* Trim comments, replace the map ref cast with an instanceof guard, and cover instance clearing
2026-07-23 10:13:44 -07:00
John Swanson
07b9ec8ffa Don't block dashboard saves on pre-existing foreign internal cards (#78186)
* Don't block dashboard saves on pre-existing foreign internal cards

UXW-4870: the guard added in #77236 (reject assigning a dashcard's card
to a question internal to another dashboard) checked updated dashcards
unconditionally, so any dashboard with a pre-existing foreign internal
card association became unsavable — every edit, even a resize, returned
400 "Invalid Request.".

Scope the guard to newly-introduced card ids: card ids the dashboard
already references (as a dashcard's card or in a series) are
grandfathered, so existing associations never block saving — including
moving such a card to a different dashcard in the same save. Newly
assigning a question internal to another dashboard is still rejected.

* Clarify arg names in internal-dashcard guard

Rename the guard's params to existing-dashboard/new-dashcards to make
clear the grandfather set comes from stored state while the checked
dashcards are the incoming save.
2026-07-23 10:08:33 -07:00
Timofey Kachalov
8ee3e5213c Update @slack/web-api to version 8.0.0 (#78438) 2026-07-23 20:53:32 +04:00
Timofey Kachalov
a2d673e0fe Enumerate data apps by reading git tree objects (#78114) 2026-07-23 20:05:12 +04:00
Alex Yarosh
4ea4064511 docs: sample database (#78390)
sample database
2026-07-23 10:37:38 -04:00
Michael Eby
55598bf0bd docs: update screenshots (#78363)
* docs: update screenshots

* Optimized 7 images, saved 357.6 KB (51.0% smaller)

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-23 10:08:53 -04:00
John Swanson
c5b6a38acb Guest embed filter dropdowns should use the router database (UXW-4881) (#78235)
* Guest embed filter dropdowns should use the router database (UXW-4881)

PR #68952 made guest-embed query execution bypass DB routing by wrapping it
in with-database-routing-off, but the parameter-values endpoints (dropdown
values, search, remapping) didn't get the same treatment: chain-filter runs
through the QP, where attach-destination-db-middleware throws "Anonymous
users cannot access a database with routing enabled."

- Wrap the query-running sections of the four param helpers in
  metabase.embedding-rest.api.common in with-database-routing-off, fixing
  all six /embed/{card,dashboard}/:token/params/* endpoints plus their
  preview_embed equivalents.
- Wrap the guest-embed tiles endpoints, which have the same anonymous-user
  bug.
- Wrap preview_embed's query-execution endpoints (query, dashcard, pivot,
  tiles) so the admin's embed preview shows exactly what the published
  embed will show (router DB data) instead of routing via the previewing
  admin's user attribute.
- Replace the manual *current-user-permissions-set*/*is-superuser?*
  bindings in the param helpers with the equivalent request/as-admin.

Public sharing has the same gap but whether routed DBs should work over
public links at all is an open product question, so it is intentionally
left out.

* Consolidate embed routing-off wraps into the shared execution helpers

Review feedback: rather than a macro that each endpoint must remember to
use, move with-database-routing-off inside the shared execution helpers in
metabase.embedding-rest.api.common (process-query-for-card-with-params,
process-query-for-dashcard, process-tiles-query-for-dashcard, and a new
process-tiles-query-for-card wrapper), which every /api/embed and
/api/preview_embed endpoint funnels through. Endpoints built on these
helpers now bypass routing automatically, so a future endpoint can't
reintroduce this class of bug.

Removes all per-endpoint wraps from embed.clj (including the two original
ones from #68952) and preview_embed.clj; the invariant now lives in one
file with one explanatory comment.
2026-07-23 06:33:37 -07:00
Nick Fitzpatrick
0669d80f2b Scope measurement container css vars (#78225) 2026-07-23 10:27:32 -03:00
Oleh K
b7d519320b Prevent scrollbar flicker from cell tooltips in TreeTable (#78337)
* Fix tooltip scrollbar flicker in TreeTable (GDGT-2822)

Hovering a cell tooltip in a virtualized TreeTable while scrolling made a
page-level vertical scrollbar flash on and off. The tooltip is portaled to
<body> and positioned with real top/left insets, and with the document root
sized to the exact viewport height, a tooltip positioned past the fold grew
the document and flashed a scrollbar.

Portal cell tooltips into the table's own scroll container instead of <body>,
so a tooltip positioned past the fold is clipped by the (already-scrolling)
table rather than stretching the page. This is wired through a small
TooltipPortalTargetProvider context so the metabase/ui Tooltip wrapper picks
up the target automatically — no per-tooltip wiring, and any TreeTable with
cell tooltips benefits (e.g. the notifications admin table).

* Address PR comment

* Fix Tooltip wrapper not forwarding ref
2026-07-23 15:16:47 +02:00
Chris Truter
26c003f858 Harden health inspector check persistence (#78295) 2026-07-23 15:06:00 +02:00
Jakub Chodorowicz
be2a5e4ea1 GDGT-2890: Fix custom-viz template .gitignore: ignore dist/ and *.tgz​ (#78355)
add dist and tgz to gitignored files
2026-07-23 14:57:19 +02:00
Nick Fitzpatrick
528ae0a381 Update table loading skeleton to use patterns (#78361) 2026-07-23 09:43:59 -03:00
Nick Fitzpatrick
4323943627 Filter out other users personal collections from search in DataStep (#78353) 2026-07-23 09:43:48 -03:00
Stas Gavrylov
b5849c9944 Monitor (#76806)
* Add monitor dir in metabase-enterprise

* GDGT-2676: Migrate Dependency Diagnostics under Monitor space

* GDGT-2677: Migrate Admin -> Tools under Monitor space

* GDGT-2676: Fix paths

* GDGT-2676: Create separate high-level directories for migrated features

* GDGT-2657, GDGT-2686: Monitor space + migrate Dependency Diagnostics (#76527)

* GDGT-2657: Introduce Monitor space layout and routing

* GDGT-2686: Migrate Dependency Diagnostics to Monitor

* Monitor: Add Monitor plugin. Cleanup + fixes

* Monitor: Update graphy ownership path for Dependency Diagnostics

* Monitor: Cleanup PR

* GDGT-2697: Update hotkeys hints for Toggle navbar

* GDGT-2684: Address Copilot review comments

* GDGT-2684: Address PR comments

* GDGT-2684: Rename SpaceLayout to AreaLayout. Split to separate files

* GDGT-2684: Refactor route guards + add tests

* GDGT-2684: Move Admin Tools pages to the Monitor space (#76606)

* GDGT-2684: Move Admin Tools to Monitor space

* Monitor: Extract diagnostics sidebar into shared Monitor sidebar outlet

Add a useMonitorSidebar context to MonitorContent so Monitor pages can
render sidebars in a dedicated right-hand region outside the padded,
scrollable main content area. Rewire Dependency Diagnostics to render its
detail sidebar through this outlet, and promote the resizable shell
(SidebarResizableBox) into shared monitor/components.

* Monitor: Add MonitorHeaderTitle component for consistent page headers

Introduce a shared MonitorHeaderTitle matching the small secondary text
style used by Data Studio breadcrumbs, and apply it across Monitor pages:
Dependency Diagnostics header, Jobs, Logs, Model cache log, Task list/runs,
Task and task-run details, Erroring questions, and Alerts management.

* GDGT-2684: Cleanup

* GDGT-2684: Update monitoring tools access permissions

* GDGT-2684: Add handling for not-found routes

* GDGT-2684: Fix not-found routes handling in Data Studio

* GDGT-2684: Extend route tests. Minor styling refactoring

* GDGT-2684: Refactor Alerts Management sidebar

* GDGT-2684: Move old SidebarResizableBox to ModelSibar

* GDGT-2684: Cleanup

* GDGT-2684: Refactor Sidebar solution to use Portal

* GDGT-2684: Fix Monitor Tools E2E specs (#76886)

* Format monitor router files after the master merge

* Import Route from metabase/router facade in AreaTab spec

Fixes the no-restricted-imports lint error banning direct react-router imports.

* GDGT-2713: Update Tasks page styling (#77088)

* GDGT-2713: Update Tasks page styling

Restyle the Monitor Tasks and Runs pages to match Dependency diagnostics:

- Replace the SettingsSection card + default tabs shell with the Monitor
  frame (fixed-height page, pills tabs; only the table scrolls)
- Rebuild TasksTable and TaskRunsTable on the TreeTable data-grid
  (sticky header, virtualized rows, skeleton loading, a11y labels)
- Move filters into a Group bar and pagination below the table with a
  total results count
- Keep the 2-state sort contract (always sorted, desc/asc) on top of
  TanStack's 3-state cycle; asc-first on all sortable columns
- Add MonitorHeaderTabs (Monitor-owned pills tabs, no data-studio
  dependency) and extract the shared LinkTab router-link tab to
  metabase/common/components

* GDGT-2713: Fix e2e test

* Migrate LinkTab to the metabase/router facade

* Remove unused Link import from PaneHeader

The Tasks styling switched the tabs to LinkTab, leaving Link unused.

* Address PR review comments and fix entity_name typing

Use strict undefined comparisons instead of loose null checks where
values can only be undefined (optional props, RTK Query error,
destructured SortingState), and strict null comparisons where the
value can only be null (Task.db_id).

Also fix TaskRun/RunEntity entity_name typing: the backend always
assocs this key (nil when the entity lookup misses), so it's
string | null on the wire, not optional.

* GDGT-2712: Update Jobs page styling (#77100)

* GDGT-2712: Update Jobs page styling

Restyle the Monitor Jobs page to match Dependency diagnostics:

- Rebuild the page on the Monitor frame (fixed-height page; only the
  jobs table scrolls, with a sticky header)
- Render the scheduler info in a Code block instead of a bare pre
  inside a settings card
- Rebuild the jobs table on the TreeTable data-grid (skeleton loading,
  empty state, a11y label, keyboard row activation); row click still
  opens the job triggers modal
- Move JobInfoApp into a folder and add unit coverage; add
  TaskInfo/Job API mocks and a task-info server mock

* Make the Monitor sidebar outlet resizable

Move the resizing behavior from the Dependency diagnostics sidebar into
the shared Monitor Sidebar so every Monitor view gets it without
duplication:

- Relocate SidebarResizableBox from metabase-enterprise/monitor into
  MonitorLayout and wrap the Sidebar portal content with it
- Sidebar API: optional resizable (default true) with containerWidth
  required while resizable, optional defaultWidth (512 by default);
  consumers measure their content area with useElementSize and pass it
- Protect the Monitor content area from text selection while dragging
  inside Sidebar instead of per-view
- Simplify DiagnosticsSidebar/DependencyDiagnostics accordingly and
  drop the now-empty metabase-enterprise/monitor/components tree
- Make the Alerts management detail sidebar resizable (fills the
  resizable box, keeps its 560px default width)

* GDGT-2712: Jobs page styling fixes

* Replace the job triggers modal with a Monitor sidebar

Clicking a job on the Jobs page now opens the job's triggers in the
resizable Monitor sidebar outlet instead of a modal:

- New JobTriggersSidebar renders each trigger as a vertical key/value
  block (all fields the modal table had), with a divider between
  triggers and a close action that returns to /monitor/jobs
- The /monitor/jobs/:jobKey route stays, so deep links and back/forward
  keep working; JobInfoApp reads the jobKey param and renders the
  sidebar, measuring its content area for the resize constraint
- Delete JobTriggersModal; add a Trigger API mock
- Give the scheduler info Code block the old card padding and keep it
  from shrinking

* Apply cross-review fixes for the jobs sidebar work

- Render trigger details as a dl/dt/dd list for proper label/value
  semantics (no visual change)
- Cover the shared Sidebar resize side effect (user-select class on the
  Monitor container across drag start/stop/unmount)
- Cover keyboard row activation, all trigger attributes with null
  placeholders and the may-fire-again 'No' branch, and the no-triggers
  empty state

* GDGT-2712: Cleanup

* GDGT-2712: Add distinct error message when job is not found

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Migrate JobInfoApp to the metabase/router facade

* Disable text selection on body while resizing the sidebar

Address review comment: dragging the resize handle otherwise causes
the browser to continuously select/deselect page text, since the
sidebar is portaled and doesn't share a DOM ancestor with the main
content to scope a CSS class to.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* GDGT-2711: Update Logs page styling (#77160)

* GDGT-2711: Extract LogsViewer to common Monitor components

* GDGT-2711: Update Logs page styling

* GDGT-2711: Cleanup

* GDGT-2711: Address PR comments

* GDGT-2711: Address PR review comments

Rename LogsViewer's process prop to processUUID, drop the unused
logs-content id, and remove a few comments flagged as redundant.

* GDGT-2661: Update Erroring questions page (styling + refactoring) (#77167)

* GDGT-2661: Restyle Erroring questions page

Rewrite the EE /monitor/errors page off the legacy audit_app viz onto the
Monitor recipe used by the other Monitor tools pages, keeping the same
backend audit query:

- TreeTable with the 11 audit columns, 2-state sortable headers, and native
  row selection; "Rerun Selected" moved to the standard bottom BulkActionBar
- Dependency-diagnostics-style debounced filter inputs
- Page no longer scrolls; the table body is the sole scroll region
- Pagination outside the table with a total count (new bad-table-count audit
  query), hidden until the data has loaded, and synced to a ?page URL param
- Requests abort the previous in-flight one on paging/sorting/filtering

Replaces the AuditParameters/AuditTable/ErrorMode stack; deletes the old
ErrorOverview and its click-mode.

* Remove dead legacy audit-table v1 code

The Erroring questions page was the last consumer of the Audit v1 table
stack. With it rebuilt on TreeTable, these are unreferenced and removable:
AuditTable, QuestionLoadAndDisplay, AuditTableVisualization (the last
"audit-table" display), AuditParameters, the bad_table card def, and
AuditMode. The rest of audit_app (unsubscribe UI, InsightsLink,
metabot-analytics) is untouched.

* Fix duplicate/incorrect cards in the erroring-questions audit query

The latest_qe CTE joined query_execution to the per-card MAX(started_at)
subquery on started_at alone, so executions across cards that shared a
timestamp fanned out — surfacing duplicate rows (and cards whose latest run
didn't actually error). Join on card_id as well. Regression test covers a
shared-timestamp collision.

* GDGT-2661: Address review feedback

- Surface count-query failures in the page error state so a failing count
  can't silently hide pagination
- Filter bar reflows (wrap) instead of clipping at narrow widths / zoom
- Skeleton renders the checkbox column to avoid a layout shift on load
- Centralize the internal-dataset-query cast to a single boundary
- Add tests: count db/collection filter clauses (backend), 202-body
  data.error path, real filter-reset assertion, rerun failure recovery

* GDGT-2661: Fold pagination total into the rows query via COUNT(*) OVER()

The page ran two /api/dataset requests per view — the bad-table rows and a
separate bad-table-count — each recomputing the expensive latest_qe scan over
all query executions. Add a COUNT(*) OVER () total_count column to bad-table so
the full filtered total rides on the rows response, and drop the count query.

Halves the round-trips and eliminates the second latest_qe scan (~2.1s -> ~1.4s
of DB work on the Stats dump); the window count adds no measurable overhead
since it runs over the already-materialized result set before LIMIT.

* Migrate ErrorOverview to the metabase/router facade

* GDGT-2661: Return erroring-questions total_count at the response root

The bad-table query computes COUNT(*) OVER () as a column, which repeated the
total on every row. Strip that column via an xform and surface the value once
as a top-level total_count on the response; the FE reads it from there.

* GDGT-2661: Non-blocking per-row rerun with loader in erroring questions

Rerun now runs in the background per card instead of blocking on the whole
batch: clicking Rerun Selected closes the bulk bar immediately and fires each
request independently, so more questions can be queued while slow ones run.
A rerunning row shows a loader in place of its checkbox, is excluded from
Select all, and its loader clears when the request settles; the list refetches
once the last rerun finishes.

TreeTable gains a generic isRowLoading prop and makes the whole checkbox cell
a click target so a near-miss no longer navigates to the row.

Also hoist total_count onto the Dataset type and drop the local helper.

* Extract shared generic sorting helpers for monitor tables

The monitor tables (erroring questions, tasks, dependency diagnostics) each
duplicated getSortingState/getSorting/isSortColumn. Extract them into a generic
metabase/utils/sorting module keyed by the column type. getSorting is overloaded
so the un-sort behaviour is type-driven: pass the current sort for always-sorted
tables (direction flips) or omit it to allow an unsorted result.

Refactor the three monitor tables onto the shared helpers.

* Update erroring questions E2E selectors for the new table

Point the tests at the TreeTable-based ErrorOverview: erroring-questions-table
container, erroring-question rows, and the always-enabled filters / bulk bar
that closes on rerun instead of a disabled button.

* Bind *driver* when reducing audit internal queries

The MySQL timestamp reader calls results-timezone-id, whose driver arg is
malli-validated as a keyword in tests. reduce-results* bound the timezone
override but not *driver*, so it was nil and audit queries returning timestamps
errored on MySQL/MariaDB. Bind *driver* to the app-db driver alongside the
override.

* GDGT-2661: Address PR comments

* GDGT-2661: Merge erroring questions input into one, extend to support name search

* GDGT-2661: Fix checkbox cell width in rows

* GDGT-2661: Fix e2e test

* GDGT-2661: Address PR comments

* GDGT-2710: Update Model cache log page styling (#77277)

* GDGT-2713: Update Model cache log page styling

Restyle to match Dependency diagnostics: the page no longer scrolls
vertically, only the table does. Replace the hand-rolled AdminS.ContentTable
with a metabase/ui TreeTable (skeleton while loading, empty state, refresh
action), and drop the emotion-styled ModelCacheRefreshJobs.styled.tsx in
favor of a CSS module.

Model and Collection columns are the two stretching columns (fill remaining
width) and support client-side sorting, since the underlying /api/persist
endpoint has no sort param.

* Unify monitor tables on 3-state sortable headers

Erroring questions and Tasks forced a 2-state flip-forever cycle by passing
the current sort back into getSorting(); drop that so a 3rd click clears the
header's sort arrow and falls back to the page's default sort, matching
Dependency diagnostics and Notifications.

The default-sorted column on each page needs sortDescFirst matching its
default direction (desc), otherwise the very first click on that column is a
no-op: TanStack's per-column cycle treats desc as the removable/last state
when sortDescFirst is false, so a column that starts at desc goes straight to
removed on click one.

* Make all applicable columns sortable in Model cache log and Jobs

Both are fully-loaded, unpaginated lists, so enabling client-side sorting on
the remaining columns is safe. Model cache log's action column stays
unsortable (no header text or data). Tasks and Task runs are left as-is:
their backends only support a fixed set of sort columns (or none), and
sorting the rest client-side would silently reorder only the current page.

* GDGT-2710: Address PR comments

* GDGT-2709: Update Alerts management page styling (#77364)

* GDGT-2709: Update Alerts management page styling

Restyle to match Dependency diagnostics: the page no longer scrolls
vertically, only the table does. Move pagination outside the table into a
plain footer and show the total result count via PaginationControls'
showTotal.

* Restrict Alerts management to admins only

The /api/notification/admin* endpoints are superuser-gated on the backend,
but the frontend route and nav link were reachable by anyone with the
broader can_access_monitoring permission (shared with the other Monitor
Tools pages), so a non-admin holding it could reach the page while every
API call inside it 403'd. Add a dedicated canAccessAlertsManagement
selector and route guard so the route/nav link match the backend's
admin-only restriction.

* Fix misleading test name in MonitorLayout spec

The test asserts the link is hidden for a non-admin analyst, not that it's
shown for an admin.

* GDGT-2794: Update Task/Run details page styling

Extract the "Back to Tasks"/"Back to Runs" breadcrumb into a shared
MonitorBackLink component, styled and positioned like other Monitor
page headers. Wrap both detail pages in the same non-scrolling shell
used elsewhere in Monitor so only the content area scrolls internally,
instead of the whole page scrolling behind the floating AppSwitcher.

* Extract MonitorPageContent to replace SettingsSection in Monitor

SettingsSection carries admin-page title/description logic that Monitor
pages don't use. MonitorPageContent keeps the same card styling with none
of that logic, dropping the last two /monitor call sites still importing
from metabase/admin.

* GDGT-2709: Fix layout in Tasks Details page

* GDGT-2709: Refactor NotificationsTable row selection logic. Fix selection with spacebar

* GDGT-2791: Abort superseded in-flight list requests in Monitor (#77694)

* GDGT-2791: Abort superseded in-flight list requests in Monitor

Changing page/sort/filter on a Monitor list page left the previous, often
expensive, list request running to completion on the server. Subscribed RTK
useXQuery hooks only abort when their last subscriber unmounts, so introduce
a shared useAbortableQuery hook built on lazy triggers (whose promise exposes
.abort()) that cancels the superseded request on arg change, unmount, and
skip transitions, and suppresses the abort error so cancellations never flash
the error view.

Caching mirrors useQuery: cache-first by default, refetchOnMountOrArgChange
to opt into always-fetch; manual refetch() always hits the network. Every
converted call site keeps its pre-migration caching semantics.

Converted: TaskListPage, TaskRunsPage, TaskRunEntityPicker,
ModelCacheRefreshJobs, NotificationsAdminPage (main list),
DependencyDiagnostics, BrokenDependentsSection, and ErrorOverview (its
one-line useAbortableAdhocQuery wrapper is inlined and hooks.ts deleted).

* GDGT-2791: Cleanup

* GDGT-2791: Fix type narrowing in TaskRunEntityPicker

* GDGT-2791: Clamp pagination range to total

* GDGT-2791: Test that tag invalidation does not refetch superseded args

* GDGT-2791: Scope query aborts per hook instance via injected cache key

* GDGT-2791: Migrate _refetchDeps to the shared __rtkCacheKey mechanism

* GDGT-2791: Refine baseQuery typings. Cleanup

* GDGT-2791: Replace interface in params declarations with type. Refine BaseQuery params type

* GDGT-2832: Group Monitor navbar items into sub-menus (#77706)

* GDGT-2832: Move AreaContent to AreaLayout and normalize Monitor page spacing

* GDGT-2832: Group Monitor navbar items into sub-menus

* GDGT-2832: Cleanup. Fix AreaTab outline overflow

* GDGT-2832: Match Alerts Management tab spacing to Dependency Diagnostics

* GDGT-2832: Replace collapsible navbar groups with static labelled sections

* GDGT-2832: Update Monitor views headings to match navbar. Fix e2e test

* Monitor section design tweaks (#78007)

* icon and spacing updates

* remove info icon from background tasks page heading

* improve empty states

* GDGT-2810: Enable sorting in all Monitor views (#77842)

* GDGT-2810: Enable sorting on all Background tasks and Task runs columns

* GDGT-2810: Remove bold styling from first Monitor table columns

* GDGT-2810: Fix reflection lint warnings in task history API test

* GDGT-2810: Qualify task_run/task_history filter columns

The entity_name and task_count sorts add joins (report_card and
report_dashboard both have their own entity_id column), so unqualified
filter columns become ambiguous: /api/task/runs?entity-id=N&sort_column=entity_name
returned a 500. Qualify the filter columns and add a regression test
combining filters with the derived-column sorts.

* GDGT-2810: Cleanup

* GDGT-2810: Use kebab-case sort params on the task runs endpoint

* GDGT-2810: Extract shared useSortingStateChange hook for TreeTable sorting

* GDGT-2810: Extract shared page/sort URL param parsers

* GDGT-2810: Fix e2e tests

---------

Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>

* GDGT-2843: Integrate Monitor upsell pages (#77948)

* GDGT-2843: Migrate Monitor upsell pages into monitor scope, link to /upgrade/monitor

* GDGT-2843: Tighten monitor module boundaries to standard shared tier

* GDGT-2843: Change Monitor app icon from gauge to pulse

* GDGT-2843: Update pulse icon and Monitor logo

* GDGT-2843: Address PR comments

* GDGT-2843: Fix e2e tests

* Fix Monitor code review findings (#78106)

* Fix Monitor code review findings

* Remove obsolete dependencies redirects

* Monitor review: Cleanup

* Address review comments

* Cleanup

* Remove Dependency diagnostics link from Data Studio nav

It already lives under Monitor (/monitor/dependency-diagnostics); the
Data Studio route just redirects there now.

---------

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
Co-authored-by: YB Tan <tybmeta@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Maz Ameli <maz@metabase.com>
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2026-07-23 13:48:39 +03:00
Ngoc Khuat
d3456cc4d1 Only execute attached cards for attachment-only dashboard subscriptions (#78352) 2026-07-23 10:25:35 +00:00
Fraser
32e4c39378 Remove video recording (#78321) 2026-07-23 22:08:04 +12:00
YB
3bef91fb42 [GDGT-2145] map more dedicated fields and leave them out of additional opts (#78412)
map dedicated fields and leave them out of additional opts
2026-07-23 16:20:40 +08:00
Romeo Van Snick
5461b53d55 Flip the unit-test harness to the v7 router engine (#78306)
* WIP: start flipping unit-test harness to v7

Default routerEngine to v7 and render bare (non-Route) children under a
catch-all route so v7's <Routes> renders them. role=link nav failures still
to diagnose. Suite not yet green.

* Give v7 tests a history handle and drop DOM-wrapped route config

The harness now owns the v7 memory history and hands specs a v3-shaped adapter
(getCurrentLocation/push/replace/goBack/listen), so the ~42 specs that drive and
assert navigation through the returned history keep working on v7.

Route config wrapped in a <div> is a v3 artifact that v7's <Routes> cannot read;
swap those two for fragments.

* Pin the v3-vs-v7 conformance suites to the v3 engine

They exist to prove the facade on v3 matches real v7 and are built from raw v3
route config, so they must not follow the harness default now that it is v7.

* Resolve a relative initial route in the v7 test history

* Buffer navigations dispatched before the v7 router registers

* Carry route props onto the v7 matched-route stubs

* Fix the v48 /browse/:dbId-:slug legacy redirect on v7

* Render facade routes in renderHookWithProviders on v7

* Migrate unit specs off v3-only routing behavior for the v7 flip

* Drop buffered v7 navigations when the router unmounts

* Default use-v7-router to true
2026-07-23 12:25:13 +10:00
Romeo Van Snick
dbeb963735 Remove use-v7-router flag from docs (#78389)
* Remove use-v7-router flag from docs

* Turn the flag of by default
2026-07-23 09:58:06 +10:00
Alexander Polyankin
8f92d99055 Replace timeout-prone fk_target_field_id cleanup migrations with NOT EXISTS (#78382) 2026-07-22 18:56:49 -04:00
Romeo Van Snick
3ee16f6e79 Re-home history@3 types onto the metabase/router facade (#78190)
* Re-home history@3 types onto the metabase/router facade

* Format the re-homed import blocks

* Merge duplicate router imports and drop unused lint directives

* Re-home the router-listen spec's history Location type
2026-07-23 07:44:27 +10:00
Maksym Yakubych
8ff4bd113d feat: remove navigate_to (#75646) 2026-07-22 20:14:25 +00:00
github-automation-metabase
6af601cba0 Stabilize treemap breadcrumb drill test (#78303)
Wait for the DebouncedFrame to settle via ensureChartIsActive() before
clicking a group tile to drill in. While the frame transitions it carries
pointer-events: none, so the drill click was silently swallowed and the
breadcrumb button never rendered.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:31:30 -06:00
Riley Thompson
cdac81f0cc Dont add previous stage sql as source query for cached query sql (#78252)
* dont add extra select for persisted query

* add repro tests

* refactor test

* pull our add-source-query helper

* remove unneeded mt/dataset

* test clean up

* add comment
2026-07-22 18:59:23 +00:00
Andrei Chernykh
628eee6db4 Migrate AuditTableVisualization to a typed function component (#78196)
* Migrate AuditTableVisualization to a typed function component

* Fix Erroring Questions column sorting by forwarding sorting props through Visualization

* Extract typed viz definition to drop casts per review
2026-07-22 09:55:39 -07:00
dpsutton
6482737957 GHY-4016: Replace Field YAMLs with FieldUserSettings in git sync (PR … (#76843)
* GHY-4016: Replace Field YAMLs with FieldUserSettings in git sync (PR 1/3)

Git sync currently exports one Field YAML per database column. For a
typical customer this is ~6396 files — 84% of the entire repo. Most of
that data (active, database_type, position, dimensions, ...) is sync
metadata that git sync has no business tracking; the user-curated part
lives in FieldUserSettings.

This PR switches git sync to export FieldUserSettings instead of Fields.
Only fields with user-curated metadata get a YAML at all.

Measured against the stats-remote-sync repo (196 published tables,
~20 columns each):

  Import:  43s → 14s  (3x faster, 7659 → 1263 files)
  Export:  6.3s → 2.0s  (3x faster, 2155 → 239 entities)
  Files:   7659 → 1263  (84% reduction)

For a large customer (563k files, 522k Fields), ingest time alone drops
from 116s to 7s.

Full Field serdes is unchanged — non-git-sync export/import is
unaffected. FieldUserSettings already had complete serdes support
(generate-path, storage-path, load-find-local, make-spec); this PR
wires it into the git sync paths.

Changes:
- `serdes/descendants "Table"`: branches on `:user-edits-only` opts.
  When set, emits `["FieldUserSettings" field_id]` only for fields with
  a FUS row; otherwise emits `["Field" field_id]` as before.
- `spec.clj`: named `git-sync-extract-opts` constant (with
  `:user-edits-only true`); public `pk-col` helper for the non-standard
  FieldUserSettings PK (`:field_id` not `:id`).
- `extract.clj` (OSS): local `pk-col` for `:where [:in pk-col ids]`
  (module boundary prevents depending on EE spec).
- `impl.clj`: `extract-chunk` uses `spec/pk-col` for FieldUserSettings.
- `models.clj`: adds `"FieldUserSettings"` to `data-model-in-collection`.
- `events.clj`: removes `:model/Field` from `register-events-for-spec!`
  so field-update events no longer create Field RSOs. A dedicated
  `::field-update-event` handler creates FieldUserSettings RSOs only
  when a FUS row exists. This completes the incremental path: full
  export uses `descendants` + `:user-edits-only`; incremental export
  uses FUS RSO rows.

Tests assert on the full RSO table so stray Field RSOs fail immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address PR review: defensive get for dynamic pk-col, fix content-hash test for FieldUserSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* don't need maps, k/v pairs work

* Declare :model/FieldUserSettings module boundary export/import

remote-sync references :model/FieldUserSettings in events.clj, so it must
be exported by warehouse-schema and imported by enterprise/remote-sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove unused f1-id binding in extract test (kondo)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Test that Field and FieldUserSettings are serdes descendants leaf nodes

Cements the design discussed in review: Table's descendants method is the
only source of field-level entries, and the :user-edits-only branch is
exclusive — you get Field refs or FieldUserSettings refs, never both via
a transitive Field -> FieldUserSettings edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:09:52 -05:00
Nicola Mometto
30021f1abd Transform run dag be (#76974)
* WIP: dag run BE

* split into new table

* run utils

* remove dag changes on job runs

* new dag run model

* abstract over parent-run

* separate dag model/api

* factor common job/dag code in a coordinated-run primitive

* fixes

* dag test

* fix migration

* kondo

* missing resolution

* exclude model

* kondo config

* fix test

* fix: exclude for deser

* fixes

* fix: exclude for deser

* fix: missing fk

* random ids

* chore: rename

* feat: add unioned runs

* remove dead endpoints

* api tests

* extend filter semantics

* trim docstrings

* kondo

* gap: allow canceling job runs

* share code

* fix: job start should return proper job id

* fix: mig to 64

* simplify

* simplify further

* simplify

* dead code

* add unique constraint on dag runs

* tighten schemas

* tighten permissions

* fix test

* relax schema

* fix: set null on delete

* handle deleted jobs/transforms for dag runs

* appease the sql linter

* fix test

* simplify rollbacks

* simplify callsite

* fix tests from bad rebase

* fix: fallback when transform is deleted

* add transform_count

* fix tests

* log with context

* varchar 32 for status

* Add UI for running transform with upstream or downstream (#77367)

* GDGT-2625 wip: reprocess DAG UI for transforms

- Split "Run now" button (upstream/downstream) + confirmation modal
- runDag / dag-transforms / cancel-dag API + analytics event
- Tabbed Runs page: transform-graph runs (default) + individual transform runs
- Graph runs table (Run/Type cols), filter bar (Type/Transform/Started/Status),
  sidebar with member transforms; shapes aligned to bronsa's /api/transform/runs
- List + members still served from a mock (transform-graph-runs.mock) via queryFn

* GDGT-2625 wip: align graph runs to /api/transform/runs, sidebar + cancel

- Reshape TransformGraphRun to backend RunSummaryResponse (run_type/entity_id/name)
- Filters mirror Transform runs: Type/Transform/Started/Ended/Status/Trigger
- Sidebar: title = Run name, Type as first metadata row, cancel section below
  metadata (dag/job/transform), confirm modal; job-run cancel endpoint wired
- Mock covers job/dag/transform in all states (succeeded/started/failed/timeout)
- Reorder columns: Status/Trigger after Duration

* GDGT-2625 wip: sidebar header entity links

Add "View this job" / "View this transform" (+ dependency graph for
transforms) action links to the graph-run sidebar header, mirroring the
individual transform-runs sidebar.

* GDGT-2625 wip: migrate graph runs to real /api/transform/runs

- Swap list + member queryFn mocks for real queries (list endpoint, dag/job
  member drill-down; standalone runs synthesized). Delete the mock fixture.
- Rewrite page spec against real endpoints via fetch-mock + new mock factories.
- Reword DAG run confirm modal (drop "in this order", bullet list) since members
  can run in parallel.

* Adjust wording

* GDGT-2625 wip: scheduled state + cleanup

- Show a "scheduled" run state on the transform page after a DAG reprocess is
  triggered, so the queued transform gives feedback instead of looking idle
- Drop dead DAG-run types/tag-providers/mock helpers left from earlier iterations

* Address code-review findings for reprocess DAG UI

Correctness:
- Migrate transforms.cy.spec.ts to /runs/individual (the /runs route now
  renders the graph-runs page, not the individual run table)
- Gate the "Run all" DAG confirm button on the mutation, preventing a
  double-click from triggering two DAG runs
- Toast when run-dag returns a null dag_run_id instead of silently no-op
- Stop the "Scheduled" banner from polling forever when the member-runs
  request errors
- Keep the graph-run sidebar open when the selected run scrolls off the
  current page during polling
- Invalidate transform state on cancelDagRun/cancelJobRun so member
  transforms' last_run refreshes after a cancel

Cleanup:
- Translate whole phrases (with context) for DAG run names
- Replace a typecast with a type guard in TypeFilterWidget
- Memoize parsed params and filter options on the graph-runs page
- Drop the unused direction field from the scheduled-DAG-run state
- Convert the sidebar's IIFE switch to ts-pattern match

* Handle deleted entities in graph runs; fix run-list cache invalidation

- Graph runs table: grey a deleted job/transform's run name (italic +
  "has been deleted" tooltip), keeping the row clickable
- Sidebar: keep showing member transforms for deleted DAG and standalone
  runs (fetched by dag-run id / synthesized from the run); hide the section
  only for deleted job runs, whose members can't be fetched without the
  (now-null) job id
- api/transform.ts: invalidate the run lists on deleteTransform (a
  transform's runs survive its deletion) and cancelCurrentTransformRun, so
  the Runs pages reflect the change instead of relying on polling
- Trim narrating comments from the transform API types

* Cleanup

* Fix transforms cancel e2e: click renamed "Cancel run" confirm button

The transform-page cancel confirmation button was renamed from "Yes" to
"Cancel run", but two cancelation tests still clicked "Yes". The cancel was
never confirmed, so the slow (pg_sleep) transform kept running and held a lock
on the writable DB, hanging the next test's resetWritableDb and failing the
suite. Point those two clicks at the new label (the individual-runs-page cancel
still uses "Yes" and is left unchanged).

* Avoid type cast in RunSection spec to satisfy no-unjustified-type-casts

Replace `body as string` with a typeof guard when reading the run-dag request
body, which the new lint rule flags.

* Show dependency count in the graph-run label

Use bronsa's new transform_count to label DAG runs as
"{n} dependencies › {name}" (upstream) / "{name} › {n} dependents"
(downstream), n = transform_count - 1, joined by a small chevron icon.
Bold the job/transform name; fall back to the plain name when the count is
absent (job/standalone runs, or DAG runs predating the column).

* Replace runs tabs with Detailed view switch; polish graph-run name rendering

- Swap the two runs tabs for a Detailed view switch that toggles grouped
  (/runs) and detailed (/runs/individual) views in place; add RunsPage shell
  and DetailedViewSwitch, remove RunTabs
- Render the sidebar header run name like the table (dimmed deps count +
  chevron), with wider gap and aligned font size/weight
- Keep the Detailed view switch label on one line
- Bump graph-runs page size to 25 to match the individual runs page
- Fix stale react-router-redux import

* Preserve shared filters across Detailed-view toggle; drop deprecated CSS util

Toggling the Detailed-view switch previously called the run-list URL helpers
with no arguments, discarding the active filters. Forward the params common to
both views (statuses, transform ids, start/end time, run methods) via a new
CommonRunListParams/pickCommonRunListParams helper; page and sort are dropped
since the two views paginate differently and use incompatible sort columns.

Also replace the deprecated CS.overflowHidden global utility class in the new
graph-run table with an inline overflow style prop.

* Track Detailed-view toggle in transform runs analytics

Emit a transform_runs_view_toggled simple event from the DetailedViewSwitch
toggle handler with the resulting view mode (detailed/grouped), so both the
grouped and individual run-list call sites are covered.

* Fix reprocess run status: don't re-show Scheduled after a member completes

- Derive the DAG scheduled state from this transform's own member run:
  a member run row only exists once the transform starts, so its presence
  means the transform already ran this cycle. Prevents isScheduled from
  re-arming (and masking the completed status) after this transform's run
  finishes while sibling DAG members are still running
- Keep the graph-run sidebar polling while the top-level run is canceling,
  mirroring isPollingNeeded

* Cleanup

* Address PR review nits from Uladzimir

- Extract isActiveRunStatus helper and use it for the duplicated
  started/canceling checks (RunButton, both isPollingNeeded, sidebar
  shouldPoll, RunSection dagFinished)
- DetailedViewSwitch: replace styles prop with a CSS module class
- Convert renderRunName/renderRunEntityName to RunName/RunEntityName
  components
- Drop redundant String() around an already-string cell value
- Cover RunButton onRun click and the run-options menu show/hide

* Adapt runs routes to the react-router migration on the rebased base

The base migrated transforms routes to element=/withRouteProps with
Outlet-based layouts. Make RunsPage an Outlet layout and render the runs
list pages via element in the specs.

* wip

---------

Co-authored-by: Oleh K <oleh@metabase.com>
2026-07-22 15:24:52 +01:00
Chris Vandoodewaard
76c36ef709 stop actionlint from linting deleted workflow files (#78342)
update file-paths filter for actionlint to ignore deleted files
2026-07-22 10:07:37 -04:00
Kamil Mielnik
70abeae733 GDGT-1933 Dashboard actions prefetch sends parameter values in URL query string (#77939) 2026-07-22 13:53:56 +00:00
Sébastien
6b1809dca1 Resolve tenant/personal collection sentinels in the question save path (#78104) 2026-07-22 15:22:07 +02:00
Timofey Kachalov
35444ee8a5 Add ability to report near membrane sandboxing blocks to a listener (#78344) 2026-07-22 17:04:12 +04:00
Timofey Kachalov
1152608228 refactor(sdk): Restructure the data-app dev server (#78251) 2026-07-22 15:04:52 +04:00
Timofey Kachalov
226175c492 Match CSP port semantics in the data-app sandbox allowlist (#78249) 2026-07-22 14:42:32 +04:00
Timofey Kachalov
7519d07ec5 refactor(sdk): Export SdkThemeProvider from the SDK bundle (#78247) 2026-07-22 14:38:53 +04:00
Romeo Van Snick
8adef47af6 Enable the react-router v7 engine by default, with the fixes it needs (#78199)
* Treat a v7 Link with an empty `to` as a button, not a navigation

* Make a v7 replace to the current URL a no-op so URL-sync effects settle

* Provide router.listen on the v7 engine and keep the router shim stable

* Serialize a v3 location query into the search string on v7

* Fix lint and type issues in the v7 router shim

* Keep the v7 matched-route branch stable across renders

* Republish the v7 splat param under v3's `splat` name

* Enable the react-router v7 engine by default

* Commit the v7 location update synchronously instead of in a transition

* Mirror the v7 location into redux from the history subscription

* Format the v7 router provider

* Sort query keys when serializing a v3 location query

* Match v3 query encoding and resolve bare link paths against the root

* Keep v3 query encoding and leave absolute link urls alone

* Always push when a v7 link targets the current url
2026-07-22 20:02:24 +10:00
Benoit Vinay
075482040b Updates to the license page (#78036)
* Updates to the license page

* Misc

* Unit tests updated
2026-07-22 10:55:11 +02:00
Nicola Mometto
fad1ba4fd7 test: bump transform-run timeouts for slow drivers (#78202)
* test: bump transform-run timeouts for slow drivers

BigQuery transform executions routinely take 50-70s in CI

* share timeout
2026-07-22 08:45:47 +00:00
Fraser
ce14a4a511 Clear old CodeQL caches (#78108) 2026-07-22 20:29:20 +12:00
Romeo Van Snick
001485bb4b Keep async vendor chunks out of the main app's initial bundle (#78304) 2026-07-22 13:32:12 +10:00
Chris Truter
73c6fa1778 Fix migration-version linter for the new backport label semantics (#78281) 2026-07-22 00:42:36 +00:00
da
5cfbe5423c Update pdfbox version from 3.0.5 to 3.0.8 (#78230) 2026-07-21 19:38:26 -03:00
Nathan Voxland
074f2a6e93 Additional transform gates (#76630) 2026-07-21 17:17:40 -05:00
Jeff Bruemmer
3e2c3661be docs - remove 63 workflow (#78265)
* remove 63 workflow

* update latest to target 63
2026-07-21 17:16:25 -04:00
Sloan Sparger
428c6be070 [Metabot] Animated markdown streaming (#78046)
* working solution

* finish

* code comment changes

* small animation adjustment

* respect reduced motion, fix tests
2026-07-21 20:15:26 +00:00
Nick Fitzpatrick
d188001d3a Loading Skeletons in Entity Picker (#78160)
* Loading Skeletons in Entity Picker

* update recent and search panels too

* renaming "LoadingSpinner" and adjusting imports
2026-07-21 16:40:01 -03:00
Uladzimir Havenchyk
c8f693f5da feat: use id-slug url for table row details (#78126)
* feat: use id-slug url for table row details

* update e2e test
2026-07-21 21:57:00 +03:00
Sloan Sparger
f8ae33f741 [Metabot] User conversation history + resumable conversations (#77549)
* conversation list endpoint

* add rtkquery endpoints

* generate conversation title

* better title generation logic, poll for title if not recieved before end of first message

* render conversation title in ui and show conversation list

* allow resuming historical conversations

* show exploration conversation history

* refresh current conversation ui for 409 case

* surface conversation titles in usage analytics

* add /metabot/conversation/:id route

* remove pending title data part, assume from lack of title data part we need to poll, fix loading a conversation that is mid-response

* title backfill job

* rename title event

* FE prevent polling multiple times for conversation title

* improve title generation prompt

* ci fixes

* automated review feedback

* e2e test fixes

* reduce diff size

* self-review

* Address PR review feedback

- api: pass profile-id through valid-usage-profile-id when ensuring a
  conversation title, matching the backfill job
- title backfill: gate readiness on check-usage-limits! (instance-level)
  so the job pauses and reschedules cleanly when a usage limit is hit
- title backfill: bound the title future .get with a 60s timeout so a
  stuck generation can't block the backfill loop indefinitely
2026-07-21 18:37:22 +00:00
Robert Kosara
830717374c Delete auditing tools page, redirect to usage analytics (#78241) 2026-07-21 17:12:23 +00:00
Maksym Yakubych
ccfc4fe280 feat: improve saving the charts (#76757) 2026-07-21 17:05:31 +00:00
Oleh K
bf0d55aac5 Add Metabot option to the Transforms New menu (#78134)
* Add Metabot option to the Transforms New menu (GDGT-1773)

Adds a "Metabot" entry as the first option in the Transforms "New" menu.
Clicking it opens the data-studio Metabot sidebar with a pre-seeded
"Create a transform that " prompt (not submitted), gated on Metabot access.

Also fixes the tiptap prompt input so a programmatically seeded prompt
places the caret at the end instead of the start (autofocus "end" plus a
caret-to-end after external setContent), which also improves the retry path.

* Cleanup

* Cleanup unnecessary code

* Test CreateTransformMenu Metabot item without mocking

Render the real CreateTransformMenu + Metabot via the shared metabot test
setup so the menu item, permission gating, and prompt seeding are exercised
end to end instead of through stubbed hooks.
2026-07-21 16:42:10 +00:00
Chris Vandoodewaard
916a3b8231 update the backport announcement (#77819) 2026-07-21 12:13:44 -04:00
Oleh K
8e45662740 Restyle Pill component to match Figma design (#77086)
* wip: Restyle Pill to Figma capsule spec

- Full-round 999px capsule, background-primary fill + shadow-xs ring
- 12px/500 text (text-primary), disabled -> text-disabled
- Remove button 12px, opacity 0.5 -> 1 on pill hover
- size 'sm' default + label slot override
- Add Storybook showcase matrix story
- Remove dead transforms components/TagList (unused)

* Adjust styles for existing Pill usages

* Add md size to Pill; center label/remove slots

- New md size (14px text, 6/12px padding, 28px tall) via .root[data-size=md]
- Flex-center the label and remove slots
- Adopt md in AddMemberRow and ReorderableTagsInput; drop dead radius=xl

* Apply design feedback to Pill padding and shadow

- sm vertical padding 2px (20px tall)
- sm removable right padding 6px; md removable right padding 8px
- Update shadow-xs token to match Figma (0.5px ring + 1px/3px drop)
2026-07-21 16:32:13 +02:00
Nicola Mometto
40e185b47f fix: use explicit ids when inserting test products on Snowflake (#78201)
Reuse the insert path added for ClickHouse in #77921
2026-07-21 14:38:23 +01:00
Chris Truter
e3a24b8db9 Bump bb pgsql pod to 0.1.6 to remove Rosetta requirement (#78200)
Bump babashka postgresql pod to 0.1.6 so mage runs on Apple Silicon

The 0.1.0 pod manifest has no macos-aarch64 entry -- only macos-amd64.
Babashka downloads that x86_64 artifact and caches it under the aarch64 path
anyway, so on an Apple Silicon machine without Rosetta every mage command dies
before doing any work:

  Cannot run program ".../mac_os_x/aarch64/pod-babashka-postgresql":
  Exec failed, error: 86 (Bad CPU type in executable)

Clearing the pod cache does not help -- the re-download is the same x86_64
binary. 0.1.5 is the first release with a real macos-aarch64 artifact; this
bumps to the current 0.1.6, which resolves to a native arm64 executable.

0.1.6 keeps every platform 0.1.0 shipped (macos-amd64, linux-amd64,
windows-amd64) and adds macos-aarch64 and linux-aarch64, so no one loses
coverage. `pg/execute!` is the only pod fn used anywhere in the repo, and its
signature is unchanged.

Verified on arm64: the cached binary is now `Mach-O 64-bit executable arm64`,
`mage.bot.setup-worktree` loads, and `./bin/mage fix-kondo-ratchets` runs to
completion.

Claude-Session: https://claude.ai/code/session_01X5dVcVX59aLonqqsYsqU65

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:17:08 +00:00
Romeo Van Snick
07909c841f Restore navigation blocking on the v7 engine and fix v7 Link navigation (#78103)
* Restore navigation blocking on the v7 engine via a blocking history

* Render the v7 Link on the v7 engine so links navigate in modals

* Scope v7 leave hooks to their route so within-route navigation is allowed

* Don't let a v7 Link with no destination navigate on click

* Type the app Link's `to` as optional to match the runtime contract

* Apply activeClassName on the v7 engine via NavLink
2026-07-21 19:09:17 +10:00
Kamil Mielnik
a3f5a5bf8a GDGT-2845 Fix Table to map dropdown search with more than 2 letters (#77946) 2026-07-21 09:00:20 +00:00
Oleh K
ec0a9ceaea Fix crashes and broken behavior in the native transform variables sidebar (#78047)
* Hide settings tab in TagEditorSidebar if there is no tags

* Don't crash query metadata fetch on un-normalizable queries

batch-fetch-query-metadata filtered empty queries before normalization,
but normalize-query returns {} when a query can't be normalized (e.g. a
native query with an incomplete field-filter/table template tag). That {}
then failed batch-fetch-query-metadata*'s strict ::query schema, 400ing the
whole request and tripping the query editor error boundary. Filter empties
after normalization too so an unnormalizable query just yields no metadata.

* Hide template-tag 'Try it' examples in transforms editor

The template-tag help sidebar's 'Try it' buttons load an example query
against the Sample Database. Transforms can't target the Sample Database,
so clicking 'Try it' switched the transform to an invalid database and left
the editor in a broken state. Add a canUseSampleDatabase QueryEditor option
(default on) that transforms set to false, which withholds the sample
database id so TagEditorHelp hides the 'Try it' buttons.

* Add regression tests for transform variables sidebar fixes

- batch-fetch-query-metadata skips queries that normalize to {} instead of
  400ing the whole request (the P1 crash)
- TagEditorHelp hides the 'Try it' examples when no sample database is available
- TagEditorSidebar hides the Settings/Help tabs until the query has a variable

* Address PR review feedback

- Simplify batch-fetch-query-metadata with (keep (comp not-empty normalize-query))
- Replace the hidden-tabs settings behavior with an empty state in the Settings
  tab (no more layout shift / auto-switch to Help); use the shared EmptyState
- Make sampleDatabaseId a required, DatabaseId-typed setup option
- Add a NativeQuerySidebar test asserting 'Try it' is hidden for transforms
  (canUseSampleDatabase=false) and shown otherwise
- Re-add the getEditorOptions canUseSampleDatabase assertion
2026-07-21 10:52:42 +02:00
Kamil Mielnik
07cb2f0a6c GDGT-2872 Revert #77695 to allow custom widget popovers (#78124) 2026-07-21 15:32:23 +07:00
Ngoc Khuat
3e9ff6e040 Bump schema-change test post-run waits on Redshift (#77945)
Bump schema-change test post-run waits to 30s on Redshift
2026-07-21 08:27:45 +00:00
Phoomparin Mano
6569bc1271 test(sdk-package): split useMetabaseQuery hook tests into smaller test suites (#78109)
* split use-metabase-query.unit.spec.tsx into multiple files

* split test fixtures from setup.tsx

* move useMetabaseQuery tests into tests folder

* rename useMetabaseQuery hook test

* remove unused query test fixture fields

* rename mock dataset query helper

* rename bundle exports

* chore: tiny formatting changes

* test: rewrite stale query results test

* test: extract public api tests to type and runtime tests
2026-07-21 05:54:16 +00:00
Anderson
a492c1091b Hide existing metrics from the metric mini picker (#78054)
* Hide metrics from the metric mini picker

* Select metrics from Browse all in E2E test

* Preserve metric picker restrictions
2026-07-20 22:33:19 -03:00
Anderson
cda8f7d301 Keep long chart tooltip values readable within the viewport (#77947)
* Wrap long chart tooltip values

* Constrain chart tooltip height
2026-07-20 22:33:10 -03:00
Andrei Chernykh
2e2b4e1298 Convert the Table visualization from a class to a function component (#78055)
* Convert the Table visualization from a class to a function component

* Stabilize the Table question so async metadata updates don't rebuild columns mid-hover

* Type useSyncedQuestion via VisualizationProps so the SDK d.ts doesn't leak internal types
2026-07-20 16:31:53 -07:00
Alexander Polyankin
7413348d68 Revert "Async workspace provisioning" (#78172) 2026-07-20 22:38:17 +00:00
Chris Vandoodewaard
71d66af5a2 Fix failure in "Re-run Flaky Workflows" workflow (#78135)
* use sendSlackMessage in workflow, update method to accept attachments and blocks

* pass slack token
2026-07-20 16:44:35 -04:00
Nathan Voxland
0de6d6ee2b Better model namespace requiring. (#76571) 2026-07-20 15:39:02 -05:00
Nick Fitzpatrick
c2decd2881 Library in Data Reference (#76917)
* Library in Data Reference

* conditional library option, adjusting table lists

* fix test
2026-07-20 17:32:41 -03:00
Chris Truter
5672dd99c6 Burn down redundant kondo ignores and stamp the false positives (#77787) 2026-07-20 20:25:02 +00:00
Romeo Van Snick
f8230a5b56 Add the react-router v7 engine behind a flag (#78020)
* Author route paths in v7 syntax, shim to v3 in the facade

* Add use-v7-router flag and frontend engine reader

* Add flag-gated react-router v7 engine (provider, tree mapper, bridge)

* Re-point redux navigation and location to the v7 router

* Support both router engines in renderWithProviders

* Switch the app mount to the v7 router behind the flag

* Point setRouteLeaveHook no-op comment at DEV-2374
2026-07-21 06:06:17 +10:00
Nathan Voxland
bfec5d9f26 Don't log user names or emails on authentication (#75539) 2026-07-20 15:01:02 -05:00
Ryan Laurie
096ce217b5 Chain the publish off of the auto-release, not the tag (#78161)
* chain the publish off of the auto-release, not the tag

* disallow auto release on forks

* thank you actionlint

* fail early

* today i learned
2026-07-20 13:33:32 -06:00
Nathan Voxland
a5ee670889 Better validate-setting logging and test handling (#77623) 2026-07-20 14:29:57 -05:00
Michael Eby
628f06a22c docs: fix markdown example link (#78146) 2026-07-20 14:08:25 -04:00
Ben Grabow
cbaff9c03e QUE2-756: Failed cache refresh (e.g. result too large) leaves refresh lease held and keeps serving stale cache (#78132)
* Delete stale cache entries after successfully running a query that shouldn't be saved to cache.
2026-07-20 17:39:25 +00:00
Alexander Polyankin
e1a1e69911 Async workspace provisioning (#78091) 2026-07-20 13:38:02 -04:00
Phoomparin Mano
b041c02f5a Move MCP url to the top and update inline origins copy (#78102)
* move mcp url field and inline origins copy

* copywriting improvements for allowed origins
2026-07-21 01:36:18 +08:00
Chris Vandoodewaard
e93496629b remove unnecessary uses of skipOn (#78048)
* remove some unnecessary uses of skipOn

* try running the slow x-ray test

* use standard @skip tag instead
2026-07-20 13:26:15 -04:00
Alexander Polyankin
9c2758cc0d Revoke schema-level grants when destroying Redshift workspace isolation (#78094)
* Revoke schema-level grants when destroying Redshift workspace isolation

destroy-workspace-isolation! discovered schemas to revoke via
svv_relation_privileges, which only surfaces relation-level grants: a granted
input schema with no tables (or whose tables were dropped after provisioning)
kept its schema-level USAGE grant, and DROP USER then failed with 'user ...
cannot be dropped because the user has a privilege on some object'. Union in
svv_schema_privileges so schema-only grants are revoked too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename workspace+det to workspace in the new destroy test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Run Redshift workspace teardown statements individually, not as a JDBC batch

A failure now surfaces as the plain server error instead of the
BatchUpdateException wrapper ('Batch entry N ... Call getNextException to see
other errors in the batch.'); the transaction still aborts as a whole on the
first error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert "Run Redshift workspace teardown statements individually, not as a JDBC batch"

This reverts commit 4bf2d241b5.

* Unwrap BatchUpdateException in Redshift workspace teardown

Keep the batch (there can be many granted schemas, two revokes each) but
rethrow .getNextException so failures read as the plain server error instead
of 'Batch entry N ... Call getNextException to see other errors in the batch.'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* remove comment

* Unwrap BatchUpdateException across all workspace-isolation batch sites

New driver.u/batch-exception util returns the per-statement exception hiding
under .getNextException (or the throwable unchanged). Applied to every
workspace init/grant/destroy executeBatch site (h2, mysql, postgres,
clickhouse, redshift) — init sites unwrap before scrub-exceptions — so
failures read as the plain server error instead of 'Batch entry N ... Call
getNextException to see other errors in the batch.'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the batch exception on init-workspace-isolation! failures

The init statements carry secrets and pgjdbc echoes the aborted statement in
the BatchUpdateException message — scrub-exceptions redacts it, and the
GHY-3660 test (init-workspace-isolation-never-exposes-password-test) relies on
that redacted CREATE USER echo as proof the run reached the statement.
Unwrapping to the inner server error discarded the echo. grant/destroy keep
the unwrap: their statements carry no secrets and the plain server error is
the better message there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unwrap the batch envelope on init-workspace-isolation! too

All workspace batch sites now rethrow the underlying per-statement exception;
init scrubs the unwrapped exception so passwords stay out of the message. The
GHY-3660 test now proves the flow reached CREATE USER by asserting the
server's permission error instead of the redacted batch echo (which no longer
exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Test batch-envelope removal for grant! and password scrubbing after unwrap

- driver-level: a failed grant-workspace-read-access! must not surface a
  BatchUpdateException (h2 runs locally; pg/mysql/clickhouse in driver CI;
  non-batching impls pass trivially)
- unit: scrub-exceptions composed with batch-exception redacts the password
  in the unwrapped per-statement message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Batch init/grant/destroy on every SQL workspace driver

Redshift grant, and all three Snowflake / SQL Server workspace methods, now
run their statements as JDBC batches like the other drivers — one round trip,
per-statement exceptions unwrapped via driver.u/batch-exception (init unwraps
before scrub-exceptions). Redshift grants commit before grant! returns, so a
subsequent describe-database from another connection still sees the
privileges; the destroy pre-loop for foreign-grantor default-priv rows stays
per-statement on purpose (each must survive the others failing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fold Redshift foreign-grantor revokes into the destroy batch

The per-statement autocommit pre-loop is gone: one transactional batch now
covers the FOR USER default-priv revokes, relation/schema revokes, and the
drops. A stale discovery row fails the whole destroy, which is fine — the
workspace layer records the failure and the deprovision retry re-discovers
fresh state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Escape workspace passwords in DDL like every other interpolated value

CREATE/ALTER USER statements interpolated the generated password raw into the
SQL string on postgres, redshift, h2, clickhouse, and snowflake (mysql and
sqlserver already escaped). Escape with sql.u/escape-sql :ansi (quote
doubling, valid on all five engines) and scrub both the raw and escaped forms
from failure messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unwrap batch exceptions from all JDBC connectors; fix sqlserver batch var scope

The grant-envelope test caught two real issues in driver CI:
- MariaDB's connector chains the per-statement exception via .getCause and
  mssql-jdbc chains nothing at all, so batch-exception now falls back to the
  cause and, failing that, rebuilds a plain SQLException with the same
  message/SQLState/error code.
- mssql-jdbc executes a Statement batch in one T-SQL scope, so the two
  sqlserver destroy statements both DECLAREing @sql collided; the variable
  names are now unique.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Type-hint the rebuilt SQLException ctor to avoid reflection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Return the batch exception as is when nothing is chained

mssql-jdbc's BatchUpdateException message is already the plain server error —
only pgjdbc adds the 'Batch entry N ... getNextException' wording. So the
unwrap falls back to the original exception instead of synthesizing a
SQLException, and the driver test asserts the envelope wording stays out of
the message rather than checking the exception class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Interpolate the escaped password in Snowflake workspace user DDL

CREATE/ALTER USER spliced the raw password while every other workspace
driver uses the ANSI-escaped value; the escaped form was already
computed and scrubbed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:17:10 -04:00
Uladzimir Havenchyk
3246bebfe4 fix: allow schemas with slash in the name (#77928)
* fix: allow schemas with slash in the name

* implement Bronsa's approach

* fixup

* fix comments

* fixup

* fixup

* fixup
2026-07-20 16:30:10 +00:00
Timofey Kachalov
b0ba83e74a Remove deleted apps on sync (#78040) 2026-07-20 20:16:17 +04:00
Chris Vandoodewaard
55554106ae ping users on Slack for failures on master/release branches (#77973)
* ping users on Slack for failures on master/release branches

* fix linting errors
2026-07-20 11:11:34 -04:00
dpsutton
64f32ddea7 Truncate data_type in BigQuery describe-fields SQL to prevent sync OOM (#78069)
A STRUCT column's INFORMATION_SCHEMA data_type spells out its entire
nested schema, and the COLUMN_FIELD_PATHS LEFT JOIN repeats it on every
nested-leaf row -- O(n^2) bytes for a column with n leaves. Dynamic-key
STRUCTs (e.g. Cloud Functions log-sink tables whose JSON payloads use
user IDs as keys) reach thousands of leaves with ~270KB type strings;
one such table produced ~4,000 rows x 271KB = 1.09GB buffered in
sync-fields!'s partition-by, OOMing the instance mid-sync.

Only short type-string prefixes are ever consumed
(raw-type->database+base-type dispatches on prefixes up to 10 chars and
stores at most RANGE<TIMESTAMP> verbatim), so SUBSTR(..., 1, 200) in the
query -- applied server-side, before the wire -- is behaviorally
identical while collapsing response size, parse cost, and the buffered
partition (1.09GB -> ~2.8MB for the table above).

The test needs no dataset or network: it captures the compiled SQL via
*process-native* and asserts both data_type columns are truncated.
2026-07-20 09:57:01 -05:00
Nicola Mometto
67de65ca3b fix clickhouse parameter rewriting on sql-tools (#78110)
* sql-tools: fix placeholde rewriting for clickhouse

* add regression test
2026-07-20 14:31:32 +00:00
Uladzimir Havenchyk
e270803b78 fix: use new router syntax (#78119) 2026-07-20 10:16:14 -04:00
Chris Vandoodewaard
d518c62a90 move global unit tests to component folders (#78058)
* remove the last test file from the legacy structure

* convert test table into format of newer tests

* remove test for undefined since this isn't a valid type for RowValue
2026-07-20 09:57:54 -04:00
Michael Eby
35cd24c2fe docs: replace markdown links (#78043) 2026-07-20 09:46:53 -04:00
Nemanja Glumac
7801008876 Restore the backend tests dependency on static-viz (#78112) 2026-07-20 15:12:55 +02:00
metabase-bot[bot]
6ab02a5d6d [chore] Update E2E test timings with recent data (#78095)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-07-20 14:40:40 +02:00
Uladzimir Havenchyk
1a6755d898 Permalinks for dbs, schemas and tables (#77274)
* handle database named permalinks (#76794)

* handle database named permalinks

* fixup

* keep urls in the bar

* add tests

* fixup

* fixup

* address review

* handle schema name permalinks (#76807)

* handle schema named permalinks

* keep urls in the bar

* fixup

* address review

* handle table named permalinks (#76814)

* handle database named permalinks (#76794)

* handle database named permalinks

* fixup

* keep urls in the bar

* add tests

* fixup

* fixup

* address review

* handle schema name permalinks (#76807)

* handle schema named permalinks

* keep urls in the bar

* fixup

* address review

* handle table named permalinks

* fixup

* empty

* collapse table permalink routes into one optional-segment route

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* address review

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* add permalink buttons (#77213)

* empty

* add permalink buttons

* fix eslint

* use named URLs in browser instead of id-slug (#77421)

* use named URLs in browser instead of id-slug

* fix linting

* add a comment

* address review

* add tests to permalinks (#77841)

* add tests to permalinks

* fixup

* fixup

* fixup

* fixup

* drop some tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:36:10 +03:00
Timofey Kachalov
7c3311ba35 Improve loader overlay for a data app (#78111) 2026-07-20 16:26:28 +04:00
Nick Fitzpatrick
9cb27d3986 Fix leaking plural template (#77606)
* Fix leaking plural template

* PR feedback
2026-07-20 09:01:51 -03:00
Chris Truter
57e0ad66ea Add kondo ignore ratchets (#77773) 2026-07-20 13:25:59 +02:00
Kamil Mielnik
74ec111ffe Prevent claude from inserting // Unjustified type cast. FIXME comments (#78035) 2026-07-20 17:02:44 +07:00
Sébastien
ccd7e61184 Set showDashboardQuestions to false by default on SDK CollectionBrowser (#78038) 2026-07-20 11:29:16 +02:00
Egor Grushin
184c415f6e GDGT-2578 Replace TokenField (#77768)
* migrate TokenField

* fix RemappedOption type for Autocomplete ComboboxStringItem

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* address RecipientPicker review: memoize maps, share lookup/label helpers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix single-value search: accept top match on Enter

Autocomplete only completes on Enter when an option is highlighted, but
search options load async so none is. Restore old TokenField behavior via
onKeyDown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix letters swallowing

* addressing the PR's comments

* added missing tests

* introduce Autocomplete + refactoring

* apply filter by clicking on enter

* fix test

* refactor autocomplete

* refactor autocomplete

* fix edge case + added unit tests

* fix type

* fix test

* fix test

* get rid of lint suppression + allow autoselect on open for multiautocomplete

* revert autoselect destructuring, restore eslint suppression (tests failing)

* explain exhaustive-deps suppression in MultiAutocomplete effect

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:59:28 +01:00
Romeo Van Snick
53c503ff0f Remove withRouter from production and delete its public export (#78019)
* Remove withRouter from production components

* Delete the public withRouter export

* Type the route-leave hook for PlainRoute and drop the route casts
2026-07-20 15:27:00 +10:00
Romeo Van Snick
a9396323f5 Author route paths in v7 syntax, shim to v3 (#78079)
Author route paths in v7 syntax, shim to v3 in the facade
2026-07-20 14:48:00 +10:00
Romeo Van Snick
04000cef07 Restructure permissions routes for v7 matching (#78021)
Restructure permissions routes to v7-compatible depth siblings
2026-07-20 03:38:37 +00:00
Romeo Van Snick
638f7e7f86 Convert the route tree to element/Outlet (#77805)
* Add element/Outlet + useRoute support to the router facade

* Convert root route tree to element/Outlet

* Convert admin route tree to element/Outlet

* Convert data-studio, models and metrics route trees to element/Outlet

* Convert transforms, account, monitor and public route trees to element/Outlet

* Convert enterprise route trees to element/Outlet and drop dead library segment routes

* Convert tenants admin route tree to element/Outlet

* Convert redirect, createEntityIdRedirect and modalRoute to element

* Ban component= on routes with eslint

* Drop component= from the facade Route type and convert remaining routes

* Drop the component= eslint rule, the facade type now enforces it

* Remove the unused withOutlet helper

* Assert the required tenantId route param instead of coercing to NaN

* Bridge element={null} to a nothing-rendering component, matching v7

* Memoize router context value so element routes re-render once per navigation

* Convert data_apps and impersonation routes merged from master to element
2026-07-20 10:34:33 +10:00
Tyler Plude
8610bfc613 [BOT-1871] Fix portable_entity_id being used for read_resource (#78074)
* fix portable_entity_id being used for read_resource

* remove entity_details references
2026-07-18 15:34:13 -07:00
Egor Grushin
8caa13a827 Remove accidentally committed __config.yml with credentials (#78084) 2026-07-18 22:07:39 +01:00
Timofey Kachalov
806e4b756c Data Apps Beta (#74840)
* initial proof-of-concept for data apps

POC 2

POC 3

POC 4

Revert "POC 4"

This reverts commit 1f62e574dc.

POC 5

POC 6

* Expose all SDK components to child realm

* Setup app publishing and serving

* Refactor distortion helpers

* Adjust skill

* Add useQuestionQuery SDK hook (#74900)

Co-authored-by: Timofei <timofei@metabase.com>

* Mock METABASE_EMBEDDING_SDK_BUNDLE for data app sdk hooks/components

* Adjust skill for TS project

* Update skill to clearly mention TS

* Add blocked APIs list to skill

* Prototype schema exports and typed hooks for custom data apps (#74978)

* add prototype for schema generation

* add formatting for generated schemas

* add skill file

* useQuestionQuery should take only ID based on @sanex3339 feedback

* add segments, measures, tables and useMetabaseQuery hook

* Add useMetabaseQuery to endowments

* Fix queryDataset

---------

Co-authored-by: Timofei <timofei@metabase.com>

* Add ability to update app bundle

* Simplify hook

* Add spinner instruction

* Mode Data Apps to OSS

* Instruct to not not use blocked event handlers

* Improve API, add styles isolation, add router components (#75418)

* Prepare skills for data app template + adjust router components (#75490)

* Fixes after rebase

* Move migration to v63

* Move migration to v63 #2

* Fix config.edn

* Fix BE tests

* Fix team name

* Fix duplicated config

* Adjust version text in skill

* Fix data app bundle uploading after legacy client removal

* Remove `actions` from config edn

* Fix failing test

* Fix failing test #2

* Add custom actions for data apps (#75627)

* Fix imports

* Fix type annotations

* Adjust migration

* Adjust permissions

* Fixed iframe security

* Data Apps publishing via remote repository POC (#75702)

* Update gitignore with data-app.html

* [PoC] Type safety and modularity improvements for custom data apps (#75381)

* allow separate libraries in schemas

* add metric type checking

* add collections parameter

* check that segments and measures belongs to table in useMetabaseQuery

* require less type information for measures

* require less type information for segments

* use metric dataset endpoint when querying measures in metrics

* add validation for breakout buckets

* fix filter operation type scoping

* expose filter type helper for nicer autocompletions

* update skill file

* add breakout schema helper for better type errors

* update breakout schema helper to accept binning and bucket

* move fields that are not used in compile and runtime to comments to shorten type errors

* enforce data truthfulness in skill

* enforce semantic layer richness in skill

* handle dimension key collisions mapped from many tables

* update skill description

* prevent hallucinated measures and breakouts

* fix field serialization

* add guidance on using filter matcher

* add skill guidance on long results and units

* accept query object in StaticQuestion and InteractiveQuestion

* extract use-metabase-query to multiple files

* update skill file to use sdk question by default

* update skill file to avoid hallucinated derived calculations

* add new functions into sandbox

* kondo and cljfmt lint fixes

* update CSP rules to allow style tags for data apps

* address review feedback on scoping and metric filters

* address code review feedback

* Add aggregations support for breakouts in query hooks (#75723)

* add aggregations support

* add additional aggregation helpers

* Add metrics support in useMetabaseQueryObject hook (#75728)

* add metric queries

* indicate metric support in skill file for useMetabaseQueryObject

* add new version for release

* fix query dataset tests

* update release workflow temporarily

* update skill description

* update skill to use more generic examples

* hotfix: allow esm usage in vite dev mode

* skill file update for table caveat

* update sandbox

* fix type errors in useMetabaseQuery

* add v0.63.2 to release workflow

* update skill file to use the 63-data-apps version

* Move data apps to the EE directory and under `data-apps` token feature (#75871)

* remove skill file alias

* Add error handling for data apps

* Add spinner while iframe is loading

* Add OSS/no token upsell banner placeholder for data apps (#75893)

* ensure a fixed height is set if container has height

* Allow including everything in data and metric library in schema endpoint (#75944)

* add filter for including library and data collections

* address review on skill file

* remove id field from rendered comment

* remove useQuestionQuery and useMetricQuery hook

* Improve env files lookup in skills (#75955)

* Allow `unsafe-eval` for data app iframe (#76004)

* Fix lint

* Fixes after rebase

* Fix duplicated test

* Improve Providers and typesafety (#75934)

* chore: reformat single expression functions to arrow

* Use entity id in questionCollections when generating typed schemas (#76230)

* allow using entity ids for question collection filter

* guide the agent towards using entity ids

* add failing test for invalid entity id

* Make custom viz work in data apps (#76237)

* Allow blob: images in data-app CSP (EMB-1923)

SDK custom visualizations load their icon assets via blob: URLs (to avoid
the cross-origin auth issue), but the /embed/data-app/ route allowed
'unsafe-eval' without the blob: scheme in img-src, so icons were blocked
under a production CSP. Add blob: to img-src scoped to the data-app iframe
route only; the main app img-src stays strict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* EMB-1880: support for `providerProps`  in data apps

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reduce schema bloat in dimensions of generated metrics when many tables are involved (#76003)

* Add useQuestionQuery SDK hook (#74900)

Co-authored-by: Timofei <timofei@metabase.com>

* Prototype schema exports and typed hooks for custom data apps (#74978)

* add prototype for schema generation

* add formatting for generated schemas

* add skill file

* useQuestionQuery should take only ID based on @sanex3339 feedback

* add segments, measures, tables and useMetabaseQuery hook

* Add useMetabaseQuery to endowments

* Fix queryDataset

---------

Co-authored-by: Timofei <timofei@metabase.com>

* [PoC] Type safety and modularity improvements for custom data apps (#75381)

* allow separate libraries in schemas

* add metric type checking

* add collections parameter

* check that segments and measures belongs to table in useMetabaseQuery

* require less type information for measures

* require less type information for segments

* use metric dataset endpoint when querying measures in metrics

* add validation for breakout buckets

* fix filter operation type scoping

* expose filter type helper for nicer autocompletions

* update skill file

* add breakout schema helper for better type errors

* update breakout schema helper to accept binning and bucket

* move fields that are not used in compile and runtime to comments to shorten type errors

* enforce data truthfulness in skill

* enforce semantic layer richness in skill

* handle dimension key collisions mapped from many tables

* update skill description

* prevent hallucinated measures and breakouts

* fix field serialization

* add guidance on using filter matcher

* add skill guidance on long results and units

* accept query object in StaticQuestion and InteractiveQuestion

* extract use-metabase-query to multiple files

* update skill file to use sdk question by default

* update skill file to avoid hallucinated derived calculations

* add new functions into sandbox

* kondo and cljfmt lint fixes

* update CSP rules to allow style tags for data apps

* address review feedback on scoping and metric filters

* address code review feedback

* Add aggregations support for breakouts in query hooks (#75723)

* add aggregations support

* add additional aggregation helpers

* Add metrics support in useMetabaseQueryObject hook (#75728)

* add metric queries

* indicate metric support in skill file for useMetabaseQueryObject

* add new version for release

* fix query dataset tests

* remove useQuestionQuery and useMetricQuery hook

* Data Apps publishing via remote repository POC (#75702)

* avoid duplicating dimension fields

namespace dimensions by table

add dimension id to mapping

pick from table fields for only allowed dimensions

fix incorrect type guards

allow querying metric using question as source

remove createMetabaseQuery from public interface

add sourceFieldId to metric

* doc generation type fixes

---------

Co-authored-by: Timofei <timofei@metabase.com>

* Add allowed hosts handling for data apps (#76094)

* Update version

* Properly include data app changes in `pull changes` modal (#76294)

* Move app template into main repo, handle skill versioning flow (#76288)

* feat(data-apps): allow passing down a card with viz settings (#75993)

support card prop in sdk question component

* Rename data app env variables and read them only in dev mode (#76604)

* Add custom data apps layout with sliding toolbar (#76600)

* Remove unused helper

* Dev env Sandboxing, Human Readable Errors and Copy Helper (#76597)

* Revert wrong rebase changes

* Improve creating filtering interfaces in custom data apps (#76401)

* update skill files to guide filter usages

* update skill file to prevent filtering mistakes

* move filtering ui patterns into a separate file

* do not treat mb_replace_me as valid creds

* use combobox for filters

* pass title false to sdk question

* combobox trigger before typing

* Add missing exported types

* Apply visualizations and visualization settings from SDK package (#76659)

* ask the agent to pull possible visualizations

* ask the agent to use card prop

* teach the agent when to use viz settings

* Support query-only SDK cards

* Remove cross references between data apps skills (#76661)

Remove data app skill cross references

* Move Data App Dev Vite App setup into SDK package (#76695)

* Don't navigate on ad-hoc question chart clicks (#76751)

* Fix sandboxing crashes and update skill file mistakes (#76858)

Fix data app sandboxing and skill guidance

* Block form submissions in data apps via CSP (#76882)

* Fix SDK theming hover text colors in generated data apps (#77083)

update guidance on colors

* Remove SDK question query prop for data apps use cases (#77080)

* Remove SDK question query prop

* update tests to use card prop instead

* Adopt Lib.createTestQuery for table sources when querying in data apps (#75957)

* Add base metabase-lib query implementation

* switch to Lib.createTestQuery DSL

* Load query metadata at runtime for metabase-lib instead of hard-coding (#76874)

* load query metadata dynamically

* accept id-only source references

* Wait for SDK login before resolving query metadata

* refactor: use useAsyncFn to handle states

* pass source-name to createTestQuery

* Add metric aggregations for useMetabaseQuery via Lib.createTestQuery (#76947)

* add metric sources for useMetabaseQuery via Lib.createTestQuery

* use MetabaseQueryObject instead of opaque type marker

* Fix metric schema and join source validation

* add metric library filtering to skill files

* make metric an aggregation expression instead of join sources

* mark tableId as never in joined fields

* export public MetabaseQueryObject type

* use camelCase for source name and field id

* Add initial data app e2e setup (#77101)

* Add sorting and fields to useMetabaseQuery via Lib.createTestQuery (#76966)

* add sorting, limiting and fields to useMetabaseQuery

* refactor: cleanup sorting and aggregations

* Fix metric order by validation

* Support saved questions for useMetabaseQuery via Lib.createTestQuery (#77073)

* add saved questions as useMetabaseQuery input

* remove extra operators for saved cards

* Add unit fe/be tests for data apps (#76979)

* Fix lint

* Use modern api in query-dataset.ts

* Fix BG color

* Fix execute-action tests

* Update SDK-data-apps version

* Move migrations to v64 folder

* Adjust router components to use metabase/router

* Allow opening a data app for any signed user

* Rename data app skills (#77266)

* Update DataAppLayout.tsx `go back` button logic

* Harden data apps skills and action validation errors (#77261)

* harden data apps skills and actions error validation

* do not display models as questions in data apps

* clarify action result types

* Agents should report errors when models, actions or questions cannot be schema generated (#77260)

* return an error if models or saved questions are invalid

* return an error if actions are invalid

* update skill file guidance on model generation

* fix model boundary

* Add data apps admin UI + routing/sandboxing/isolation e2e (#77271)

* Rename data-apps paths to /apps (#77298)

* Include all action-included models in the typed schema when a mutation is specified (#77291)

* add includeModels=true flag to include all models

* omit model columns and models without actions

* remove extra metadata

* Adjust admin UI for Data Apps (#77420)

* Add more e2e/unit tests for Data Apps (#77524)

* Update bun.lock

* Fix errors after rebase

* Parallelize dts rollup (#77663)

* revert EMB-1840 measures support in metric dataset endpoint

* chore: add data app module (#77705)

* test ci

* apply code review suggestion

* Setup separate entries for client and node dev data app entries (#77687)

* Fix type casts lint errors (#77696)

* Adjust data apps setup + add proper analytics setup (#77604)

* Extract common and scope helpers + tests for typed schema module (#77403)

* refactor: extract common and query param helpers

* refactor: use kebab-case for query params

* refactor: cleanup schema mapping

ci: fix module config

* refactor: rename schema scope helpers to scope.clj

* refactor: extract scope_test.clj

* Extract typed schema rendering helpers to render.clj (#77703)

* extract schema render function

* move runtime compact test out

* remove javascript and json endpoints from schema generator

* refactor: cleanup javascript.clj and render.clj

* Extract typed schema generators for tables and questions (#77729)

* refactor: extract schema generator modules

* remove question-details from model-schemas

* fix: batch queries in table and question schema generator

* remove N+1 in typed schema field table lookup

* Extract typed schema generators for models and actions (#77739)

* extract model schema generator

* batch model action querying to avoid n+1 queries

* Extract typed schema generator for metrics (#77806)

* Extract typed schema generators for models and actions (#77739)

* extract model schema generator

* batch model action querying to avoid n+1 queries

* extract metric schema generator

* test: simplify fields in metric_test fixtures

* refactor: clean up metric schema generation

* remove n+1 from metric field dimension read

* disable default temporal dimension breakout

* surface errors if metric schema cannot be built

* revert metrics/api.clj to master to remove outdated comments

* Fix routes

* Take a data app's slug from its directory name (#77688)

* Merge data-apps specific migrations into one

* ci: fix failing backend test and cljfmt lint

* Add a beta badge in data apps admin page heading (#77877)

marks the data app as beta

* Revert "Merge data-apps specific migrations into one"

This reverts commit 383a5fa28d.

* Revert "Revert "Merge data-apps specific migrations into one""

This reverts commit 948d904b62.

* ci: actionlint fix

* Fix data apps route

* Use getSubpathSafeUrl in attachIframeUrlMirror

* Fix comment

* Do not send apps with sync error for token check

* Add data app listing sidebar section for non-admins (#77977)

* show data apps in sidebar for non-admins

* add app icon to data apps

* use available=true to filter no sync error and enabled apps

* fix navbar keys in data apps

* test: use non-admin user to assert non-admin response

* fix toucan query in data apps listing endpoint

* re-export entity detail helpers and result column to metabot core

* open data apps in new tab from the sidebar

* Prevent race conditions in responses of useMetabaseQuery hook (#78023)

prevent stale responses in useMetabaseQuery

* ci: remove tools.util from module declaration

* Merge migrations into a single one

* reset release-embedding-sdk.yml to master

---------

Co-authored-by: Phoomparin Mano <poom@metabase.com>
Co-authored-by: Phoomparin Mano <phoomparin@gmail.com>
Co-authored-by: Nicolò Pretto <info@npretto.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:42:50 +01:00
Egor Grushin
8dd86422fe GDGT-2577 Replace deprecated SchedulePicker component (#77911)
* Replace deprecated Schedule

* addressing styling issues

* addressing typing issues

* addressing missing tests

* addressing converting the component to an until

* addressing cron test coverage

* fix e2e subscription tests for Schedule migration

- target Mantine Select via data-testid (select-frequency/select-frame)
  instead of findByDisplayValue (dupe visible+hidden inputs)
- lowercase frequency option labels
- assert token popover not.exist (unmounts on Escape)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:23:59 +02:00
Fraser
f051eb94a5 Compile e2e CLJS in parallel with the uberjar build (#78017) 2026-07-18 15:12:22 +12:00
Timothy S. Dean
3df3238697 Consistency pass over database routing invariants (#78066)
Some cleanup of some false alarms I encountered in test environments while working on a different issue. Destination databases (those with `router_database_id` set) are reached only through their router: metadata, sync, permissions, etc. Only execution goes against the destination's connection. A `data_permissions` row pointing at a destination is never consulted -- it's already actively stripped.

There are test utilities that helpfully create in-test permissions for all databases created, so in a test environment it would look exactly like the invariant was being violated, but that was only because the test utilities deviated from production behavior. This brings the test behaviors into line with production (no permissions get written).

There should be no change in production behavior in this PR, but as a bonus, I figured that a safety guard filtering rows out on reads is not as good as filtering them out on reads *and* failing loudly on writes. I also added guards that a normal database can't be turned into a destination database, which was a behavior tests were relying on for easier setup (but again, the tests then deviated once more from production behavior).
2026-07-17 20:39:45 -06:00
Rodrigo López Dato
3a00f09bfd Disable Liquibase usage analytics (#77992)
Since 4.28 Liquibase ships an opt-out analytics feature that, on the first
migration run, fetches config from config-endpoint.liquibase.com and sends
anonymized usage events.
2026-07-17 23:28:40 +00:00
github-automation-metabase
f5bbb2bfde Auto-update documentation for master (#78062)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-17 21:27:21 +00:00
Mihael Konjević
898f75a2b1 Parse :absolute-datetime string literals in repr resolve (#76436)
* Parse :absolute-datetime string literals in repr resolve

Metabot's representations repair pass wraps bare ISO `between` bounds as
`[:absolute-datetime {} "2024-01-01" :day]` to satisfy the lib `:between`
temporal schema, leaving the literal as a string. That form is schema-valid
(so it passes construct_notebook_query validation), but the JVM query
processor expects a real java.time value — optimize-temporal-filters throws
on a bare string, and the QP's own wrap-value-literals only parses temporal
strings that arrive as raw values, not ones already wrapped in
:absolute-datetime.

Parse those string literals into java.time values in resolve-query, where the
portable repr form becomes QP-ready pMBQL, alongside the existing
annotate-field-types pass.

* Address review: tighten docstrings, use update over assoc

- parseable-temporal-string?: contract-first two-line docstring
- parse-absolute-datetime-literals: move the "why" into inline comments at
  the walk, keep the docstring to contract + idempotency
- use (update node 2 u.date/parse) instead of assoc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review: year/year-month, fail-fast, export round-trip

- parse year/year-month :absolute-datetime literals too (u.date/parse maps
  them to LocalDate), so they are QP-ready instead of crashing later
- fail fast on a structurally-ISO but invalid literal (e.g. "2024-13-45"):
  re-throw as an :agent-error? ex-info so the LLM gets a clean message
- export: format java.time values back to ISO-8601 strings in
  portable-repr-form so resolve -> export round-trips to the documented
  string form
- tests: per-shape parsing (date/datetime/offset/year/year-month),
  idempotency, invalid-literal, and export round-trip

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Move :absolute-datetime string parsing to the QP layer

Revert the resolve-layer parsing and instead teach wrap-value-literals to
parse string-valued :absolute-datetime clauses, not just raw values. The
repair pass wraps bare `between` bounds as `[:absolute-datetime {} "2024-01-01"
:day]`; wrap-value-literals previously only parsed temporal strings that
arrived as raw values, so a pre-wrapped string slipped through and
optimize-temporal-filters threw on the bare string.

The QP is the right layer: wrap-value-literals runs at execution time, where
both the report timezone (bound) and the comparison field's type are
available — so the parse is type- and timezone-aware for free, and the stored
query keeps its portable ISO-string form (no export round-trip to invert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fail fast on malformed :absolute-datetime literals in resolve

Add a resolve-side validation pass that rejects temporal literals which pass
the schema's structural regex but are not real dates (e.g. "2024-13-45"), so
the agent gets an actionable error at construct time instead of a failure at
query execution.

Validation only — it does not coerce. The string -> java.time coercion stays
in the QP's wrap-value-literals, where the field type and report timezone are
available. Accepts every valid :absolute-datetime shape: date, datetime,
offset-datetime, year, and year-month.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Preserve unit and wrap mixed :between bounds for string :absolute-datetime

When re-parsing a string-valued :absolute-datetime in wrap-value-literals:

- Carry the clause's own temporal unit through instead of discarding it. The
  comparison field's bucket still wins, but for an unbucketed field the literal's
  unit is now the fallback (rather than :default), so a year/year-month literal
  like [:absolute-datetime {} "2024" :year] keeps its bucket instead of silently
  collapsing to a single instant.
- Wrap each :between bound independently so a mixed bound (string :absolute-datetime
  on one side, :now / :relative-datetime / an already-parsed :absolute-datetime on
  the other) still coerces the wrappable side instead of falling through unwrapped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Guard comparison literals and tidy :absolute-datetime validation

- wrap-value-literals: guard the field position in the :=/:!=/<…> arms so a
  comparison with a wrappable literal on both sides can't bind a literal as the
  field and leave it unparsed; it now falls through unchanged.
- resolve: validate :absolute-datetime literals via lib.walk/walk-clauses
  (no tree rebuild, shares clause-of-type? detection with the QP) instead of
  clojure.walk/postwalk.
- Move cross-subsystem rationale out of the string-valued-absolute-datetime
  docstring into an inline comment.
- Add a test covering the two-literal comparison fall-through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Parse fieldless :absolute-datetime comparisons and bound bad-literal errors

Wrap a string-valued :absolute-datetime on both sides of a comparison
(no field to wrap against) so each literal is parsed with its own unit
instead of leaving a bare string for later middleware to choke on.

Truncate the untrusted literal before it lands in the agent-facing
exception message and ex-data, so a pathological value can't flood logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Treat :default field unit as unbucketed for :absolute-datetime literal fallback

A temporal field can carry :temporal-unit :default (add-default-temporal-unit
stamps it on :temporal/requires-default-unit drivers, and an LLM can author it
directly). The unit-fallback guard only skipped a nil unit, so a :default field
dropped the literal's own unit and collapsed e.g. [:absolute-datetime {} "2024"
:year] to a single :default-bucketed instant. Treat :default as unbucketed too.

* Validate :absolute-datetime literals with plain walk to keep resolve gate friendly

validate-absolute-datetime-literals walked the query with lib.walk/walk-clauses,
a mu/defn whose input schema validates the whole query against ::lib.schema/query.
Under test instrumentation that threw a raw "Invalid input" error on an otherwise-
malformed query (e.g. an :offset in :expressions), pre-empting the friendlier
query-not-runnable agent error the downstream canRun gate is meant to surface.
Use clojure.walk/postwalk and a bare [:absolute-datetime _ s _] shape check, matching
the sibling annotate-field-types pass, so only the literals are inspected.

* Use match/match-many for :absolute-datetime literal validation, add mixed-between raw-value test

Addresses PR review feedback: swap the manual walk/postwalk in
validate-absolute-datetime-literals for match/match-many, and cover a
:between with a raw (unwrapped) string bound plus a :relative-datetime
bound in wrap_value_literals_test.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:16:14 +02:00
Alexander Polyankin
05fa507ed7 Workspaces cleanup (#77875) 2026-07-17 17:08:49 -04:00
Nathan Voxland
d1f282210c Improving LLM error handling (#78003)
- Handle mid-stream failures better
- Better retry detection + handling
- Detect invalid json better
2026-07-17 15:17:44 -05:00
Eric Normand
f0ce91f74c Fix Remote Sync push hang: honest stalled UI + meaningful export progress (GHY-4132) (#77904)
* Show honest "still working" state when a Git sync push stalls (GHY-4132)

A large push can stop reporting progress while the export is still running; the
backend then reports the task as :timed-out with ended_at nil. The push modal
treated that as neither success nor error and stayed frozen on "Exporting
content…" until the page was refreshed.

Render a distinct stalled state instead: drop the frozen progress bar and show
"Still working. No update for N minutes." (internationalized, singular/plural
aware). The counter derives from last_progress_report_at and stays fresh via the
existing 2s current-task poll — no separate ticker.

* Add throttled monotonic progress reporter for remote-sync tasks (GHY-4132)

* Ramp remote-sync export progress across plan + serialize phases (GHY-4132)

* Report remote-sync export commit checkpoint before push (GHY-4132)

* Report remote-sync push progress via JGit ProgressMonitor (GHY-4132)

* Fix throttle, dead 0.66 write, and push liveness in export progress (GHY-4132)

commit-staged! was passed a pre-forced report closure, so every push
ProgressMonitor tick bypassed the 10s throttle and hammered the DB. Force only
the one-shot commit checkpoint (0.8) explicitly in finish-commit!, and let
push ticks go through the throttled reporter.

The forced 0.66 serialize-boundary write ran after commit-staged! returned,
by which point the 0.8 checkpoint (and push ticks) had already advanced the
monotonic reporter past it, so it was silently dropped. Move it to the end of
each export's stage-fn, before the checkpoint/push run.

The push monitor only reported during the exactly-titled "Writing objects"
phase with a known positive total; a non-English JVM locale renames that
title and JGit may report an unknown (zero) total, so the push phase could go
silent for its whole duration and re-trip the stale-progress timeout. Report
on every update tick regardless of phase/total, falling back to the 0.8
push-start fraction — repeated equal values are monotonic no-ops but still
bump last_progress_report_at, throttled to at most once per 10s.

* Use dayjs diff for the stalled-progress minute counter (GHY-4132)
2026-07-17 20:12:29 +00:00
Nick Fitzpatrick
a8234bd97c Overhaul pie chart static viz. Fix leaking HSLA values (#77303)
* Overhaul pie chart static viz. Fix leaking HSLA values

* PR feedback:

* label column adjustment to match previous behavior

* fe lint
2026-07-17 16:35:43 -03:00
Jeff Bruemmer
55e1aa315d docs - 2FA (#77883) 2026-07-17 15:06:45 -04:00
Eric Normand
8c87c3489a Expose search's ranked set and page-hydration as separate steps (#78005)
* Expose search's ranked set and page-hydration as separate steps

`search` already ran in two stages: rank the permission-filtered result set
(capped at max-filtered-results), then slice that set to the requested page
and hydrate only those rows. The comment on `search-results` spells out why
the slice comes first — hydration does per-row work, including the expensive
`can_write` check, so it should only touch rows that are actually returned.

Both stages were sealed inside `search`, so a caller holding several ranked
sets had no way to combine them and paginate the combination. Its only option
is to ask `search` for N hydrated pages and merge those, which hydrates N
pages to return one, and — because each page is sliced from its own ranking
before merging — cannot page the merged order correctly at all.

Split the two stages into `ranked-results` and `search-results` and export
both from the module API. `search` is now their composition and behaves
identically; no call site changes.

The motivating caller is metabot's multi-query search, which runs a search per
query and fuses the rankings with RRF. Fusing at the ranked-results seam lets
it hydrate only the page it returns, page the fused order coherently, and
report a total for the fused set.

* Remove comments from search core.clj

Removed comments explaining the purpose of `ranked-results` and `search-results`.

* kick ci
2026-07-17 13:53:48 -05:00
Rodrigo López Dato
7297aaaa8a Clarify what SLO does (#77761) 2026-07-17 14:40:10 -03:00
Jeff Bruemmer
eb8d806ffe docs - custom viz sdk (#77974) 2026-07-17 12:53:23 -04:00
Andrei Chernykh
f08dbabf1b Extract Table viz definition into a typed definition file (#77983) 2026-07-17 09:40:50 -07:00
Nick Fitzpatrick
1ec6f1d2d3 Add missing monthsListControl style to DateInput (#77627) 2026-07-17 13:24:30 -03:00
Andrei Chernykh
8fd7008e90 Convert sandboxes and advanced_permissions plugin modules to TypeScript (#77487)
* Convert sandboxes and advanced_permissions plugin modules to TypeScript

* Address review comments: remove typecasting from sandboxes actions and unit spec
2026-07-17 09:16:29 -07:00
Timur Mashara
144a139152 Revert 'GRO-554 update storage upsell (#76052)' (#78034) 2026-07-17 12:08:44 -03:00
Sébastien
d7a87295df Add pagination and sorting to MCP analytics tool calls (#77599) 2026-07-17 16:42:55 +02:00
Nick Fitzpatrick
cd9d166077 Fix trend viz in FE PDFs (#77612) 2026-07-17 11:08:38 -03:00
Sébastien
95267311e5 Hide dashboard questions in CollectionBrowser by default (#77941) 2026-07-17 14:35:14 +02:00
Cam Saul
74af0a368d [MongoDB] remove :metadata/key-constraints driver feature flag (#77659)
[MongoDB] remove `:metadata/key-constraints` driver feature flag (incorrectly set)
2026-07-17 12:52:04 +02:00
12212 changed files with 2071143 additions and 1617928 deletions

View File

@@ -27,9 +27,28 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
- Format using Markdown conventions
- Reference other vars with `[[other-var]]` not backticks
A docstring states the *contract*: what it returns (including when it returns nil), what its
params mean, what it throws versus swallows, and any precondition or invariant the caller must
respect. A params block for non-obvious or optional args is good documentation — length is not
the problem.
These seven things are, and each is a reason to cut or relocate:
| Anti-pattern | Why | Where it goes instead |
| --- | --- | --- |
| **Facts you don't own** — how a *callee*, another namespace, or another repo works internally | Drifts silently; nothing fails when it goes stale | Cut. Name the callee and let its own docstring say it |
| **Said twice** — ns docstring restating its vars', a caller restating its callee's, sibling fns repeating boilerplate | Two copies means one is already wrong | Cut the copy. One fact, one owner |
| **Design rationale** — why this approach, why it lives in this module, what the alternative was | The caller can't act on it | Commit message, or an inline comment on the specific line it constrains |
| **Development history** — "matching master", "the X branch", "currently", "for now", "used to" | Stale by construction | Cut |
| **Instructions to the reader** — "callers should trust this", "don't bother checking", "you should use X" | State the contract; the caller decides | Cut, keeping any actual guarantee as a plain statement |
| **Invented precision** — score bands, thresholds, or tables no code enforces | Reads as spec, isn't | Cut, or make the code enforce it |
| **A note on the wrong var** — describing how overrides behave, on the base/default impl | Nobody reading the override sees it | Move to the overrides it describes |
**Comments:**
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
- Prefer an inline comment over docstring prose for anything that explains *the body*. Put it on
the line it explains, not at the top of the function.
## Code Organization

View File

@@ -6,3 +6,4 @@
- Run targeted tests, and lint continuously during development
- Prioritize understanding existing patterns before implementing
- Don't commit changes, leave it for the user to review and make commits
- Minimize comments. Document only non-obvious intent, trade-offs, or constraints that the code itself cannot convey. Do not narrate what the code does — keep code self-descriptive through naming and structure.

View File

@@ -149,8 +149,7 @@ ms/NonBlankString ;; Non-empty string
ms/BooleanValue ;; String "true"/"false" or boolean
ms/MaybeBooleanValue ;; BooleanValue or nil
ms/TemporalString ;; ISO-8601 date/time string (for REQUEST params only!)
ms/Map ;; Any map
ms/JSONString ;; JSON-encoded string
ms/OpaqueJSONObject ;; JSON object whose keys are not ours to declare; string-keyed
ms/PositiveNum ;; Positive number
ms/IntGreaterThanOrEqualToZero ;; 0 or positive
```

View File

@@ -10,23 +10,23 @@ This skill helps you add product analytics (Snowplow) events to track user inter
## Quick Reference
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
Analytics events in Metabase use Snowplow with typed event schemas. Simple events are declared **where they are used** — `trackSimpleEvent` is generic and validates the payload at the call site.
**Key Files:**
- `frontend/src/metabase-types/analytics/event.ts` - Event type definitions
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry
- `frontend/src/metabase/analytics/` - Core tracking functions (import from `metabase/analytics`)
- Feature-specific `analytics.ts` files - Tracking function wrappers
- `frontend/src/metabase/analytics/event.ts` - Core tracking functions, `trackSimpleEvent` / `trackSchemaEvent` (import from `metabase/analytics`)
- `frontend/src/metabase-types/analytics/event.ts` - The shared `SimpleEventSchema` only. **Do not add event types here** (see below)
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry (custom/legacy schemas only)
- Feature-specific `analytics.ts` files - Where your tracking functions and any local types live
## Quick Checklist
When adding a new analytics event:
- [ ] Define event type in `frontend/src/metabase-types/analytics/event.ts`
- [ ] Add event to appropriate union type (e.g., `DataStudioEvent`, `SimpleEvent`)
- [ ] Create tracking function in feature's `analytics.ts` file
- [ ] Import and call tracking function at the interaction point
- [ ] Use `trackSimpleEvent()` for basic events (most common)
- [ ] Pick an event name (snake_case, past tense)
- [ ] Add a tracking function to the feature's `analytics.ts` file, calling `trackSimpleEvent()`
- [ ] Keep any field unions (e.g. `"success" | "failure"`) as local types in that same file
- [ ] Import and call the tracking function at the interaction point
- [ ] Do **not** add an event type to `metabase-types/analytics/event.ts` or to any union
## Event Schema Types
@@ -47,6 +47,25 @@ type SimpleEventSchema = {
**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
`trackSimpleEvent` is generic and enforces this schema on the object literal you pass it:
```typescript
// frontend/src/metabase/analytics/event.ts
export function trackSimpleEvent<
T extends SimpleEventSchema &
Record<Exclude<keyof T, keyof SimpleEventSchema>, never>,
>(event: T) {
trackSchemaEvent("simple_event", event);
}
```
That means a missing `event` or any field outside `SimpleEventSchema` is a compile error at the call
site. There is no separate event type to declare and no `satisfies` clause to add — the old
`ValidateEvent<...>` helper is no longer exported and is not part of the workflow.
`trackSchemaEvent` is generic too: it correlates the schema name with the payload type, so you can't
send a dashboard event under the `simple_event` schema.
### 2. Custom Schemas (legacy, no events are being added)
Consider adding new event schema only in very special cases.
@@ -57,36 +76,7 @@ Consider adding new event schema only in very special cases.
### Example: Track when a user applies filters in a table picker
#### Step 1: Define Event Types
Add event type definitions to `frontend/src/metabase-types/analytics/event.ts`:
```typescript
export type DataStudioTablePickerFiltersAppliedEvent = ValidateEvent<{
event: "data_studio_table_picker_filters_applied";
}>;
export type DataStudioTablePickerFiltersClearedEvent = ValidateEvent<{
event: "data_studio_table_picker_filters_cleared";
}>;
```
#### Step 2: Add to Union Type
Find or create the appropriate union type and add your events:
```typescript
export type DataStudioEvent =
| DataStudioLibraryCreatedEvent
| DataStudioTablePublishedEvent
| DataStudioGlossaryCreatedEvent
| DataStudioGlossaryEditedEvent
| DataStudioGlossaryDeletedEvent
| DataStudioTablePickerFiltersAppliedEvent // <- Add here
| DataStudioTablePickerFiltersClearedEvent; // <- Add here
```
#### Step 3: Create Tracking Functions
#### Step 1: Create Tracking Functions
In your feature's `analytics.ts` file (e.g., `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`):
@@ -106,7 +96,7 @@ export const trackDataStudioTablePickerFiltersCleared = () => {
};
```
#### Step 4: Use in Components
#### Step 2: Use in Components
Import and call the tracking function at the interaction point:
@@ -138,16 +128,11 @@ function FilterPopover({ filters, onSubmit }) {
## Using SimpleEventSchema Fields
All examples below live in the feature's own `analytics.ts` — nothing is registered centrally.
### Example: Event with target_id
```typescript
// Type definition
export type DataStudioLibraryCreatedEvent = ValidateEvent<{
event: "data_studio_library_created";
target_id: number | null;
}>;
// Tracking function
export const trackDataStudioLibraryCreated = (id: CollectionId) => {
trackSimpleEvent({
event: "data_studio_library_created",
@@ -162,14 +147,10 @@ trackDataStudioLibraryCreated(newLibrary.id);
### Example: Event with triggered_from
```typescript
// Type definition
export type NewButtonClickedEvent = ValidateEvent<{
event: "new_button_clicked";
triggered_from: "app-bar" | "empty-collection";
}>;
// Local union, exported only if another feature needs to pass the same value
export type NewButtonLocation = "app-bar" | "empty-collection";
// Tracking function
export const trackNewButtonClicked = (location: "app-bar" | "empty-collection") => {
export const trackNewButtonClicked = (location: NewButtonLocation) => {
trackSimpleEvent({
event: "new_button_clicked",
triggered_from: location,
@@ -187,44 +168,31 @@ export const trackNewButtonClicked = (location: "app-bar" | "empty-collection")
### Example: Event with event_detail
```typescript
// Type definition
export type MetadataEditEvent = ValidateEvent<{
event: "metadata_edited";
event_detail: "type_casting" | "semantic_type_change" | "visibility_change";
triggered_from: "admin" | "data_studio";
}>;
Real example — `frontend/src/metabase/metadata/pages/shared/analytics.ts`:
// Tracking function
export const trackMetadataChange = (
detail: "type_casting" | "semantic_type_change" | "visibility_change",
location: "admin" | "data_studio"
) => {
```typescript
export type MetadataEditEventDetail =
| "type_casting"
| "semantic_type_change"
| "visibility_change";
export const trackMetadataChange = (detail: MetadataEditEventDetail) => {
trackSimpleEvent({
event: "metadata_edited",
event_detail: detail,
triggered_from: location,
triggered_from: "admin",
});
};
// Usage
trackMetadataChange("semantic_type_change", "data_studio");
trackMetadataChange("semantic_type_change");
```
### Example: Event with result and duration
```typescript
// Type definition
export type MoveToTrashEvent = ValidateEvent<{
event: "moved-to-trash";
target_id: number | null;
triggered_from: "collection" | "detail_page" | "cleanup_modal";
duration_ms: number | null;
result: "success" | "failure";
event_detail: "question" | "model" | "metric" | "dashboard";
}>;
See `frontend/src/metabase/archive/analytics.ts` for the real version of this.
// Tracking function
```typescript
export const trackMoveToTrash = (params: {
targetId: number | null;
triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
@@ -280,17 +248,16 @@ try {
"filters-applied" // Use underscore, not hyphen
```
### Event Type Names (PascalCase with "Event" suffix)
### Local Field Types (PascalCase, named after the field)
There is usually no `...Event` type to name anymore. When you do need a union for a field, name it
after the field it feeds:
```typescript
// Good
DataStudioLibraryCreatedEvent
TablePickerFiltersAppliedEvent
MetabotChatOpenedEvent
// Bad
dataStudioLibraryCreated // Wrong case
DataStudioLibraryCreated // Missing "Event" suffix
type MetricDimensionResult = "success" | "failure"; // -> result
export type MetadataEditEventDetail = "type_casting"; // -> event_detail
type NewButtonLocation = "app-bar" | "empty-collection"; // -> triggered_from
```
### Tracking Function Names (camelCase with "track" prefix)
@@ -309,29 +276,28 @@ logLibraryCreated // Use "track" prefix
## Common Patterns
### Pattern 1: Feature-Specific Union Types
### Pattern 1: Sharing Field Types Across Features
Group related events together:
When two features send the same event with a different `triggered_from`, export the field union from
the owning feature's `analytics.ts` and import it — don't hoist anything into `metabase-types`:
```typescript
export type DataStudioEvent =
| DataStudioLibraryCreatedEvent
| DataStudioTablePublishedEvent
| DataStudioGlossaryCreatedEvent;
// frontend/src/metabase/data-studio/data-model/analytics.ts
import { trackSimpleEvent } from "metabase/analytics";
import type { MetadataEditEventDetail } from "metabase/metadata/pages/shared/analytics";
export type MetabotEvent =
| MetabotChatOpenedEvent
| MetabotRequestSentEvent
| MetabotFixQueryClickedEvent;
// Then add to SimpleEvent union
export type SimpleEvent =
| /* other events */
| DataStudioEvent
| MetabotEvent
| /* more events */;
export function trackMetadataChange(detail: MetadataEditEventDetail) {
trackSimpleEvent({
event: "metadata_edited",
event_detail: detail,
triggered_from: "data_studio",
});
}
```
This is the point of the extensible-events design: enterprise and feature-tier types stay in their
own module instead of being imported down into a shared union.
### Pattern 2: Conditional Tracking
Track different events based on user action:
@@ -350,10 +316,10 @@ const handleSave = async () => {
## Common Pitfalls
### Don't: Add custom fields to SimpleEvent
### Don't: Add custom fields to a simple event
```typescript
// WRONG - SimpleEvent doesn't support custom fields
// WRONG - SimpleEventSchema doesn't support custom fields (this is a compile error)
export const trackFiltersApplied = (filters: FilterState) => {
trackSimpleEvent({
event: "filters_applied",
@@ -379,23 +345,43 @@ export const trackFilterApplied = (filterType: string) => {
};
```
### Don't: Forget to add event to union type
### Don't: Add event types to `metabase-types/analytics/event.ts`
The central `SimpleEvent` union was removed — it forced feature-tier types to be imported down into
shared code, causing module-boundary violations. `trackSimpleEvent` is generic now, so the type adds
nothing but duplication.
```typescript
// Define the event
// ❌ WRONG - central declaration + re-import for a `satisfies` clause
// frontend/src/metabase-types/analytics/event.ts
export type NewFeatureClickedEvent = ValidateEvent<{
event: "new_feature_clicked";
target_id: number;
}>;
// ❌ WRONG - Forgot to add to SimpleEvent union
// Event won't be recognized by TypeScript
// frontend/src/metabase/my-feature/analytics.ts
import type { NewFeatureClickedEvent } from "metabase-types/analytics";
// ✓ RIGHT - Add to appropriate union
export type SimpleEvent =
| /* other events */
| NewFeatureClickedEvent;
export const trackNewFeatureClicked = (id: number) => {
trackSimpleEvent({
event: "new_feature_clicked",
target_id: id,
} satisfies NewFeatureClickedEvent);
};
// ✓ RIGHT - the object literal is already checked by the generic
// frontend/src/metabase/my-feature/analytics.ts
export const trackNewFeatureClicked = (id: number) => {
trackSimpleEvent({
event: "new_feature_clicked",
target_id: id,
});
};
```
A few `...Event` types still sit in `metabase-types/analytics/event.ts`. They are leftovers from PRs
that landed around the refactor — don't copy them, and don't add to them.
### Don't: Mix up event name formats
```typescript
@@ -463,41 +449,44 @@ Example console output:
### Where to put tracking functions:
```
Feature-specific analytics functions:
Tracking functions AND their local field types (this is where new events live):
frontend/src/metabase/{feature}/analytics.ts
enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
Event type definitions (all in one place):
frontend/src/metabase-types/analytics/event.ts
Core tracking utilities:
frontend/src/metabase/analytics/ (import from `metabase/analytics`)
Shared SimpleEventSchema only — nothing new goes here:
frontend/src/metabase-types/analytics/event.ts
```
In embedding SDK code, use `trackSdkSimpleEvent`
(`frontend/src/embedding-sdk-bundle/analytics/snowplow.ts`) instead — the main-app `"sp"` tracker
isn't initialized in the customer's page, so `trackSimpleEvent`'s Snowplow leg is a no-op there.
## Real-World Examples
See these files for reference:
- **Simple events**: `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`
- **Events with variants**: `frontend/src/metabase/dashboard/analytics.ts`
- **Complex events**: `frontend/src/metabase/query_builder/analytics.js`
- **Event type examples**: `frontend/src/metabase-types/analytics/event.ts`
- **Simple events + local field union**: `frontend/src/metabase/metadata/pages/shared/analytics.ts`
- **Reusing another feature's field type**: `frontend/src/metabase/data-studio/data-model/analytics.ts`
- **Result + duration timing**: `frontend/src/metabase/archive/analytics.ts`
- **Enterprise feature events**: `enterprise/frontend/src/metabase-enterprise/google_drive/analytics.ts`
## Workflow Summary
1. **Identify the user interaction** to track
2. **Decide on event name** (snake_case, descriptive)
3. **Define event type** in `event.ts` using `ValidateEvent`
4. **Add to union type** (create feature union if needed)
5. **Create tracking function** in feature's `analytics.ts`
6. **Import and call** at the interaction point
7. **Test** that events fire correctly
3. **Create tracking function** in feature's `analytics.ts`, calling `trackSimpleEvent()`
4. **Add local field unions** in that same file if a field has a fixed set of values
5. **Import and call** at the interaction point
6. **Test** that events fire correctly
## Tips
- **Be specific** - `filters_applied` is better than `action_performed`
- **Use past tense** - `library_created` not `create_library`
- **Group related events** - Create feature-specific event union types
- **Group related events** - Keep a feature's tracking functions together in its `analytics.ts`
- **Track meaningful actions** - Not every click needs tracking
- **Consider the data** - What would you want to analyze later?
- **Stay consistent** - Follow existing naming patterns in the codebase

View File

@@ -56,6 +56,11 @@ Use this to scan through changes efficiently:
- [ ] References use `[[other-var]]` not backticks
- [ ] `TODO` comments include author and date: `;; TODO (Name 2025-01-01) -- description`
Docstring content — check every new or rewritten docstring against the anti-pattern table in the
style guide above. Read the diff's docstrings on their own, separately from the code: prose is
where review attention slides off, and a long docstring reads as diligence rather than as the
liability it usually is.
### Code Organization
- [ ] Everything `^:private` unless used elsewhere
@@ -135,6 +140,17 @@ Quick scan for common issues:
> This public var needs a docstring explaining its purpose, inputs, and outputs.
**For docstrings documenting code they don't own:**
> This describes how `lib` decides binning strategies internally. Nothing here fails when that
> changes, so it will go stale silently — drop it and let `available-binning-strategies` answer
> for its own behavior.
**For docstring content that belongs in the body:**
> This paragraph explains why the branches are ordered this way, which is what someone editing
> the `cond` needs — but a caller can't act on it. Move it to an inline comment above the `cond`.
**For organization issues:**
> This function is only used in this namespace, so it should be marked `^:private`.

View File

@@ -82,10 +82,29 @@ either a bug, or it means callers must handle each case themselves — and
in *that* case it is the *absence* of the property that must be
documented. Document deviations from expectation, not conformance to it.
Heuristic: if a sentence would still be true after a full rewrite of the
body, it may belong in the docstring. If it describes *how the current
body works*, it belongs in the body — as an inline comment, if it is
non-obvious.
Heuristic — a sentence earns its place in the docstring only if it
passes both tests:
1. Rewrite test (necessary, not sufficient): if rewriting the body to a
different implementation with an identical contract could make the
sentence false, it describes the implementation, not the contract.
Relocate it to an inline comment at the point in the body where it
applies.
2. Ownership test: if the sentence states a fact about code this
function does not own — a callee's behavior, a library's guarantee —
it survives any rewrite trivially, which is exactly why the rewrite
test alone is not enough. If this body *relies* on that fact, it is
implementation context: relocate it to the call site. If nothing here
relies on it, it is blather about someone else's code: delete it, and
let the callee's own docstring answer for it.
Say each fact once. Duplicated prose is a stale comment in advance: when
the fact changes, one copy gets updated and the rest quietly rot. This
holds even within code you own — a namespace docstring that
re-summarizes its own multimethods, three sibling functions carrying the
same paragraph about retry behavior. Give the fact one home, and have
the other places name it rather than restate it.
Multi-line docstrings are not banned — a genuinely non-obvious constraint
the code had to deal with can be worth explaining. But be prudent; the

View File

@@ -37,14 +37,16 @@ First check if snapshots exist:
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
```
Always pass the spec path via `--spec`. The runner feeds its arguments to Cypress's CLI parser (`cypress.cli.parseRunArguments`), which silently drops bare positional paths and then runs the entire suite. A leading `run` argument is harmless, but the path itself must follow `--spec`. Sanity-check that the first `Running:` line says `(1 of 1)`.
If snapshots exist, skip regeneration for speed:
```bash
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress --spec path/to/spec.cy.spec.js
```
If snapshots are missing (first run), let the runner generate them:
```bash
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress $ARGUMENTS
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress --spec path/to/spec.cy.spec.js
```
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
@@ -67,9 +69,11 @@ GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
When the user asks to verify a test is not flaky:
```bash
MB_EDITION=oss bin/e2e-stress-test $ARGUMENTS
MB_EDITION=oss bin/e2e-stress-test --spec path/to/spec.cy.spec.js
```
(The stress script forwards its arguments to `bun test-cypress`, so the same `--spec` rule applies.)
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
## Analyzing failures

View File

@@ -0,0 +1,248 @@
---
name: fe-modularization
description: Deciding where frontend code lives — tier model, move mechanics, extension points, side effects, naming, and the traps. Use when moving code between modules, carving new modules, fixing boundary violations, adding a barrel or an endpoint, or reviewing module-shape decisions.
---
# Frontend modularization
The module is the unit of blast radius: dependency direction, test selection,
bundle cones, and review scope all follow module boundaries. Every decision
here serves one goal: **make the file locations tell the truth about who owns
what and who may depend on whom.**
## Hard rules (check these before anything else)
- NEVER add an `export … from` re-export at an old path, and NEVER `export *`
from a barrel. Move the code, codemod every call site, delete the old path,
all in the same PR.
- NEVER import past a module's `index.ts` when that module has
`enforcePublicApi: true`, and never past `metabase/ui`'s index at all. If
the barrel is heavy, the fix is making the module side-effect free (below),
not a deep import.
- NEVER rewrite a consumer to a lower-level idiom to make the linter pass. If
the fix makes the consumer worse, the plan is wrong.
- NEVER give a new module `enforceSharedTiers: false`; new modules ship
enforced with `enforcePublicApi: true` and an explicit `index.ts`.
- NEVER move code without the consumer-tier check (procedure step 2).
- ALWAYS delete a module's `enforceSharedTiers: false` line in the PR that
takes it to zero violations.
- ALWAYS measure `bun run module-boundaries` before and after, and report the
numbers.
- ALWAYS import `dayjs` from `metabase/dayjs`, routing from `metabase/router`,
Mantine from `metabase/ui`, react-redux hooks from `metabase/redux`; the
raw specifiers are lint-forbidden.
## Files that are ground truth
| what | where |
|---|---|
| module elements, tiers, `enforceSharedTiers`, `enforcePublicApi` | `frontend/lint/module-boundaries.mjs` |
| shared sub-tiers and levels, cluster rules | `frontend/lint/shared-tiers.mjs` |
| standalone boundaries lint (all violations, incl. grandfathered) | `bun run module-boundaries` (config `eslint.config.module-boundaries.mjs`) |
| PR lint (only enforced modules fail CI) | `bun run lint-eslint-pure` |
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
## The model
Tiers: `lib < basic < shared < feature < app`. Imports point downward, never
sideways at feature tier (features may not import features; app and EE may
import anything). The shared tier is subdivided in `shared-tiers.mjs` into
shared-utils (U0…), shared-platform (P0…), and shared-domain, each ordered
into levels. A module imports only *strictly lower* levels of its sub-tier
plus the sub-tiers below; same-level peers are deliberately forbidden, which
is what makes cycles structurally impossible. A domain may hold two seats
when consumers demand it: a low core and a high surface (metabase-lib below
questions below query_builder; viz-core below visualizations).
Enforcement is per module: `enforceSharedTiers: false` on an element exempts
it from the level rules (counted by `bun run module-boundaries`, invisible to
PR lint). Modules with `enforcePublicApi: true` may only be imported from
outside via `metabase/<module>`, and import relatively inside.
## Deciding where code lives (apply in order; first decisive test wins)
1. **Who owns the concept?** Code lives with the module that owns its
concept, not the module that renders or calls it. A mode encoding dashboard
click behaviour is dashboard code even if viz executes it. Metabot
conversation state is metabot state even though the store registers it.
2. **Consumer-tier check.** The destination must sit at or below the file's
*lowest* consumer. A single consumer below the proposed home vetoes the
move; report it, don't force it.
3. **Only four legitimate fixes** for a bad edge: move the code to its
terminal home; invert through a designed extension point (a prop, an
injected component, a contract type owned by the socket); fix the tier
placement when the declared level is wrong; delete a thin wrapper (a rename
with no derivation isn't an abstraction — deleting it *is* the migration).
4. **A bridge lives above the lower endpoint.** Code that needs both sides of
a boundary belongs in or above the higher module.
5. **"Genuinely shared" is proven by consumers.** A shared address needs two
or more consumers the tier rules can't serve another way. One consumer
pretending to be shared is a feature module at the wrong address.
## Extension points
The socket's owner defines the contract; every plug lives with its extender.
Visualization owns the `mode` prop and click interfaces, each surface supplies
its mode; the editor owns its extension contract, metabot supplies tiptap
extensions; querying owns `TemplateTagsSidebarProps`, parameters implements
it.
- Prefer plain props/injection at composition sites. `PLUGIN_*` registries are
for edition gating; single-reader slots move out to their reading module;
slots with many readers live in the module that owns the concept (every
slot the whitelabel plugin fills lives in `whitelabel`), tier permitting.
Keep contracts type-light so implementations can load lazily.
- Identical injection at every callsite is acceptable until a fourth consumer
or second slot appears; then the composition deserves its own module above
both parts. Don't pre-build the wrapper.
## Store shape
A module that has redux state owns it:
- `store/` is private: reducer, plain creators/action types, and selectors,
typed against the module's own state (`QueryBuilderStoreState`), never the
global `State`. Thunks live in `actions/` and import `store/`, never the
reverse.
- The barrel is the state API: selectors for reads, creators/thunks for
writes.
- Store roots (`reducers-main`, `reducers-public`, the SDK store) import each
slice from its module barrel and derive their own state type; no slice
registry, no `declare module` merging.
- Cross-module writes: a callback prop when a composition point exists;
otherwise dispatch the owner's exported creator; an event only when the
emitter must not know its listeners. Raw `dispatch({ type: "…" })` strings
are banned. A reducer or listener matching another module's *internal*
action is the coupling to remove.
- Slices that mirror one endpoint get deleted (the RTK cache is the state); a
fold over many sources with one writer (the metadata store) is kept as a
module-private slice behind the barrel.
## Endpoints
Endpoints migrate to their owning module via `Api.injectEndpoints` from the
module's `api/` file (settings, metabot, transforms precedents); `metabase/api`
trends toward the client only. There is one `Api` object per backend because
tag invalidation only works within one instance. Consumers reach endpoints
only through the owner's exports (`useGetCardQuery`, `cardApi.endpoints.…`),
never by name through the base object; `metabase/no-base-api-access` allows
injection only in `metabase/api` and `**/api/**` / `**/api.ts`, and reaching by
name only in `metabase/api` and test support (`invalidateTags` /
`resetApiState` are fine anywhere). Each endpoint move carries its
metadata-store hookup: the owner calls `entitiesFetched` from its own
`onQueryStarted`.
## Import-time side effects
Directories in `SIDE_EFFECT_FREE_PATHS` are marked `sideEffects: false` for
rspack, so importing one export from their barrel no longer drags the whole
barrel into a bundle. The price: nothing in those directories may do work at
import time, or production silently drops it. `metabase/no-module-side-effects`
enforces this over every listed directory (module-scope calls, writes to
imported objects or globals, bare imports, control flow at import; escape
hatches are `/* #__PURE__ */` and the rule's pure-callee list). Files that
must run at import are listed in `SIDE_EFFECT_PATHS` (files or directories).
- **A library the app configures gets a facade module** owning the vendor
import, the registrations, and the types, with the raw specifier
lint-forbidden outside it: `metabase/router`, `metabase/ui`,
`metabase/redux`, `metabase/dayjs`. Facades are the effect, so they never
enter `SIDE_EFFECT_FREE_PATHS`. Never re-register a vendor plugin locally
(`dayjs.extend`, `echarts` `use`).
- **A patch a component needs is an explicit registration module,
bare-imported by the provider that needs it** and listed in
`SIDE_EFFECT_PATHS` (Mantine's `Popover.Dropdown` replacement, imported by
`ThemeProvider`).
- **Effects with no value to import** (global CSS, polyfills, the CSP nonce,
EE plugin bootstrap) live in the entry's side-effects file and nowhere else.
- **Registries are filled from the composition root at boot**
(`initializePlugins`, `registerVisualizations`), never by a module
registering itself at import.
- **RTK injection stays at import**; when a module with an `api/` folder is
declared side-effect free, list `<module>/api/` in `SIDE_EFFECT_PATHS`.
## Naming
The feature keeps the plain product-surface name; a shared carve is named for
what the shared part actually is: the concept when the concept is the shared
thing (`settings`, `current-user`, `whitelabel`, `dayjs`), or a qualified
capability when the plain noun is taken (`search-ui`, `metrics-ui`,
`viz-core`, `data-studio-ui`). Never two modules distinguished only by tier
prefix; never generic `-shared` / `-feature` suffixes.
## Procedure for a move
1. **Enumerate consumers honestly** (hook-name greps miss the last three):
```
grep -rn "metabase/<module>" frontend/src enterprise/frontend/src frontend/test e2e
grep -rn "jest.mock(\"metabase/<module>" frontend enterprise/frontend
grep -rn "addMatcher(.*<endpoint>" frontend/src enterprise/frontend/src
```
plus sibling files importing the moved file relatively. Classify each by
module and tier from `module-boundaries.mjs` / `shared-tiers.mjs`.
2. **Consumer-tier check**: destination at or below the lowest consumer. If
not, stop and report the blocking consumer.
3. **Measure before**: `bun run module-boundaries 2>&1 | tail -1` on the base
(write the number down).
4. **Move**: `git mv`; codemod every importer to `metabase/<module>` (outside)
or a relative path (inside); add named exports to the destination barrel
for every symbol an outside consumer needs (each must have a real
consumer). Delete the old path. If the module didn't exist: add its element
in `module-boundaries.mjs` (ordered before any element it's carved from,
first match wins), its level in `shared-tiers.mjs`, `enforcePublicApi:
true`, an `index.ts`. If the move empties a module's violations, delete
its `enforceSharedTiers: false` line. If the module has an `api/` folder
and is side-effect free, list `<module>/api/` in `SIDE_EFFECT_PATHS`.
5. **Verify** (all of these, report the numbers):
```
grep -rn "<old path>" frontend enterprise/frontend e2e # must be empty
bunx eslint --no-warn-ignored <touched files>
bun run lint-eslint-pure # enforce-module-public-api included
bun run type-check-pure
bun run test-unit-keep-cljs <touched folders' specs>
bun run module-boundaries 2>&1 | tail -1 # after; no violation may name the new files
bun run build-release:static-viz # only if viz/static-viz/ui/dayjs touched; hard 3.5 MiB budget
```
6. **Report**: count before/after (state count-neutral explicitly), the edges
removed by file, flags/rules deleted, barrel exports added, anything you
skipped and why, and any consumer you found that vetoed part of the move.
## Traps (each has bitten at least once)
- **Late binding.** A bare function reference in a `createSelector` input
array, a component identity captured for comparison, a `PLUGIN_*` object
read at module scope: all capture at import time and dodge jest spies, EE
overrides, and lazy loading. Read at call time (`(_state) => fn()`), or
inject.
- **Plans go stale.** Verify every recipe against current code before
executing; things get merged, renamed, or turn out never to have existed.
Skip-and-report beats force.
- **The linter's blind spots**: `export … from` re-exports, bare-specifier
packages (custom-viz), pre-seeded caches that never register tags. A grep
for the old path is the stronger claim than a green lint run.
- **Path-keyed baselines reset on rename**: a generated per-file ledger
silently loses a file's history when it moves.
- **A heavy barrel is a bundle hazard until its module is side-effect free.**
One constant imported from the `metabase/ui` barrel pulled Mantine into the
static-viz bundle and broke its budget; the fix was `SIDE_EFFECT_FREE_PATHS`,
never a deep import.
- **Type augmentation is program-wide.** `declare module "dayjs"` (and
similar) makes plugin types available to any file in the same tsconfig, so
a passing type-check never proves the setup ran; only importing the facade
does.
- **Environment before diagnosis**: after a dependency-touching merge,
`bun install` before declaring a failure real; worktrees share staleness
through the node_modules symlink, and a stale `target/cljs_dev` makes
`type-check-pure` fail on `metabase-lib/metric/core.ts` (rebuild cljs in the
worktree).
## PR shape
One logical change per PR; the body names the specific edges removed (files,
imports, before/after count) and gives each move's ownership reason in plain
language a reviewer outside the effort can follow, without campaign labels.
Stack only for semantic dependency or same-hot-file serialisation, and say
so. Consider codeowner surface when chunking. Open as a draft until measured
and green.

View File

@@ -13,14 +13,14 @@ allowed-tools: Read, Grep, Bash, Glob, Skill
**Primary standard: the [`typescript-write`](../typescript-write/SKILL.md) skill.** Load it first — it defines the authoring rules this review enforces, alongside `frontend/CLAUDE.md` and `docs/developers-guide/frontend.md`.
Adherence to `typescript-write` is the **highest-priority** review dimension: rank any violation of its provisions above all other findings. Treat its **no-`any` hard rule** (no explicit *or* implicit `any` in new code) as **blocking**, and verify it with the LSP rather than by eye.
Adherence to `typescript-write` is the **highest-priority** review dimension: rank any violation of its provisions above all other findings. Treat its **no-`any` hard rule** (no explicit *or* implicit `any` in new code) as **blocking**. Use TypeScript LSP tools to inspect inferred types when available; otherwise rely on type-checking and linting.
Review in this priority order:
1. **Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule.
1. **Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, function signatures, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule. Apply conditional guidance in context: explain the unsupported type guarantee or concrete readability problem, rather than treating every preference as a blanket ban.
2. Compliance with `frontend/CLAUDE.md`.
3. Readability and maintainability.
4. Appropriate test coverage.
4. Appropriate test coverage. For internal typed callers, avoid requesting tests solely for inputs the type system excludes. External API data, deserialised values, storage and JavaScript callers can violate annotations: test runtime validation and nontrivial assumptions at those boundaries. Types do not replace behavioural, security or data-integrity tests.
## Blind spots — act as the missing reviewer
@@ -31,3 +31,4 @@ These rarely surface in team reviews, so this skill should raise them. They are
- **Security.** Evaluate potential security issues in new code.
- **Bundle size.** Flag new large dependencies, default imports from icon or util libs, and heavy modules imported at route-load time.
- **Analytics.** User-facing flows should emit tracking events. If a PR adds a new flow (button, modal, navigation) without a tracking event, ask whether one is expected.
- **Public API surface** (embedding SDK). Consumers should be able to use public signatures and name types they need to import. Export those types deliberately and document public behaviour, including `@deprecated` for deprecated APIs; a referenced structural type does not automatically need its own named export.

View File

@@ -13,26 +13,34 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **New code must not introduce `any`, explicit or implicit.** No `any` annotations, no `as any` / `as unknown as`, no untyped parameters or returns that infer `any`, no implicitly-`any` destructures or array/object literals.
- **Untyped third-party / boundary values** must be typed at the boundary (a declared type, `unknown` + type guard, or a small typed wrapper) — never let `any` propagate inward.
- **Mandatory LSP verification.** After writing or editing any TS/TSX, inspect the changed symbols with the TypeScript Language Server (hover to read inferred types; `goToDefinition` to confirm sources) and run the project type-check.
- **Mandatory type verification.** Before finishing a TS/TSX change, run `bun run type-check-pure`. If TypeScript LSP tools are available, also inspect changed symbols with hover and go-to-definition; otherwise skip LSP check.
## Type tightening
- **Avoid type casts and loose `unknown`** — fix the signature instead.
- **If a function only needs one field of a wide object, accept that field** — not the wide object. The cast often disappears once the signature is right.
- **Reach for `Partial<T>`, `Pick<T, K>`, `Record<K, V>`, and generics** before reaching for a cast.
- **Match dictionary keys to the data.** Use a finite key union when the keys are known. Open dictionaries can use an index signature, `Record<string, T>`, or `Map`; index signatures still constrain values, and changing their spelling to `Record<string, T>` does not make missing-key access safe.
- **Prefer making props/components generic** (`<T>`) when a value flows through unchanged and the caller knows the type.
- **Prefer `unknown` over loose typing** and narrow before use — an `unknown` value forces a guard at the point of use.
- **`satisfies` for object literals** that must conform without widening (config objects, lookup maps, discriminated literals) — better than `: T` (widens) or `as T` (unsafe).
- **Avoid non-null assertions (`!`)**. Prefer a guard, early return, or `?.`. Use `!` only when non-nullness is provably true and localized, with a comment.
- **Guard indexed lookups that may miss.** Arrays and dictionaries can return `undefined` even when the inferred type omits it. Use an iteration form that preserves the key/value relationship, and only assert `keyof` when the runtime keys are known to belong to the declared type.
- **No redundant runtime coercion** — don't wrap already-typed values in `Number()` / `String()` / `Boolean()`.
- **Type guards belong in `frontend/src/metabase-types/guards/`**. Do not redefine them locally.
- **A cast you can't avoid needs a real justification comment.** The `metabase/no-unjustified-type-casts` rule accepts any preceding comment — state the actual reason the cast is safe. NEVER write the legacy `// Unjustified type cast. FIXME` placeholder; it exists only on casts that predated the rule, and copying it sneaks an unjustified cast past the linter. If you can't articulate why the cast is correct, the cast is wrong — fix the types.
- **Keep unavoidable assertions local.** Isolate a repeated or complex assertion behind a helper when that makes its invariant easier to enforce. Test nontrivial runtime assumptions that justify it; a trivial assertion does not automatically need a new helper or test. Do not weaken a public signature just to silence implementation errors.
## Type modeling
- **Reuse existing types; don't re-declare them.** Use canonical IDs and domain entity types from `metabase-types/api` (`FieldId`, `TableId`, `ConcreteTableId`, `SchemaName`, …) and key data structures by them (`new Map<ConcreteTableId, …>()`). Don't duplicate generated/API types — compose or derive (`Pick`, `Omit`, indexed access `SomeType["field"]`, `ReturnType`).
- **Use generics to allow TypeScript to infer correct types** when creating functions and components that need to be reusable and type-safe. Don't hesitate to introduce complex generics if they allow to derive types automatically instead of manual narrowing.
- **Generics must make promises the implementation can keep.** A caller-selected `get<T>(): T` must not disguise an unchecked assertion about external data. Return `unknown` and validate, or accept a validator that establishes `T`. A factory such as `function empty<T>(): T[] { return []; }` is valid; judge the implementation, not how often `T` appears in the signature.
- **Prefer an honest type over false precision.** Use generics when they express a real relationship. If a complex type cannot model the behaviour accurately, choose a simpler type or `unknown` with narrowing instead of asserting an unsupported guarantee.
- **Model the actual data contract; keep types narrow.** Optional `field?: T` for a key that may be absent, `field: T | undefined` only when the key is always present but the value may be undefined, `| null` for explicit API nulls. Prefer domain unions over broad `string` / `number` / loose `Record`.
- **Refer to API implementation** when defining or refining types to ensure they match the actual data structure. When considering a type cast, first consider if the type should be refined to match the actual data structure.
- **Optionality must reflect absence.** Keep required fields required and optional fields optional. Normalise input when the application has a meaningful default, not merely to remove a type error. Preserve the actual wire shape in API types.
- **Represent related absence together when modelling internal state.** If several fields exist or disappear together, consider a nullable containing object or a discriminated union. Do not reshape a raw API declaration unless the data actually has that shape.
- **Prefer explicit special states when designing a format.** Use nullability or named union variants when sentinel values such as `-1` hide meaning. Preserve established protocol sentinel values unless the behaviour is deliberately changed, or normalise them at an explicit boundary.
- **Discriminated unions for variant state, with exhaustive checks.** Model "one of N shapes" as a union with a literal discriminant rather than a bag of optional fields, and exhaust it with ts-pattern's `.exhaustive()` so adding a variant becomes a compile error:
```ts
import { match } from "ts-pattern";
@@ -44,20 +52,30 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
.exhaustive(); // Compile-time guarantee all cases handled
```
- **Derive union types from constants** (`as const` + `typeof`/`keyof`) so the type and the values can't drift.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config, and unmutated parameters. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate. Functions that mutate caller-owned data should make that behaviour explicit.
- **Construct complete, well-typed objects where practical.** Prefer an object expression when it avoids partially initialised state or assertions. Incremental construction is fine when it is clearer and maintains the type's invariants.
- **Treat `metabase-lib` opaque types as black boxes.** Use `metabase-lib` functions to work with types such as `Lib.Query`, rather than casting into their internal representation.
- **Type async and error states explicitly** (a discriminated union or the data-layer's typed result) — never leave loading/error/empty implicit.
## Function signatures
- **Make public contracts explicit where it improves stability and clarity.** Annotate parameters and return types at shared boundaries when useful; let local values infer. Use `satisfies` or an annotation when a declaration needs an explicit shape check.
- **Accept the inputs the operation supports and return the most precise honest result.** Narrow avoidable uncertainty inside the function, but preserve meaningful nullability and union variants in its return type.
- **Use named options when positional arguments are easy to confuse.** Consecutive arguments with the same type are a useful warning sign, not an automatic requirement to rewrite a clear API.
- **Use `async`/`await` when it clarifies control flow or error handling.** Returning an existing promise directly is also valid; a Promise return type alone does not require adding `async`.
## Null and undefined
- **Narrow at the source**. If a value is optional only in a corner case, don't thread `undefined` through every layer — guard at the producer.
- **Sensible defaults for optional values**. Use `?.` and `??` at the consumer.
- **Lists should be filtered** before being used in a map or other iteration.
- **Narrow nullable list elements when the operation requires present values.** Filter with a type guard when missing entries should be discarded; preserve them when their absence or position carries meaning.
- **Avoid non-strict null comparisons** (`X != null`) when `X` can never be `null` — use a strict check or narrow the type. Use `checkNotNull` where necessary.
- **Check actual nullability against API implementation**. Find the API endpoint implementation and check if the field can actually be null.
## Naming
- **Names describe the entity, not the mechanism**. A name must reflect what the value holds.
- **Use domain vocabulary and include units where useful** (`timeoutMs`, `widthPx`, `temperatureC`). Prefer a more specific name when `Info`, `Data`, or `Entity` obscures the meaning, while keeping established terminology when it is clear.
- **Align sibling concepts**: keep verb conventions consistent across a related API.
- **No names that encode implementation history** rather than current meaning. Suffixes like `Base`, `New`, `Old`, `Initial` need a real semantic distinction, otherwise drop them.
- **Avoid cryptic identifiers** (`v`, `n`, `$n`) for domain values; short names are fine only in tiny conventional contexts (loop index `i`, coordinates `x`/`y`, generic params `T`/`K`/`V`).
@@ -74,10 +92,6 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **No comments by default**. Well-named identifiers carry the `what`.
- **Comments should be concise**. Add a short, concise comment only when the `why` is non-obvious: a workaround, a hidden invariant, a subtle ordering constraint, a clever reduction. Never document the actual implementation, focus on the intent and the why.
## TypeScript Migration
**When touching existing JavaScript files, propose to convert them to TypeScript first**. Create a separate PR for the conversion, then implement the changes.
## Verify before done
- **Run the project type-check** when finished (see the shared TypeScript commands above).

View File

@@ -7,7 +7,6 @@
:aliased-namespace-symbol {:level :warning}
:case-symbol-test {:level :warning}
:condition-always-true {:level :warning}
:def-fn {:level :warning}
:dynamic-var-not-earmuffed {:level :warning}
:equals-expected-position {:level :warning, :only-in-test-assertion true}
@@ -50,9 +49,11 @@
;; disabled linters
;;
:constant-condition {:level :off} ; TODO (sashko): too confused by defenterprises, some hook/config is needed
:redundant-ignore {:level :off} ; lots of false positives, see https://github.com/clj-kondo/clj-kondo/issues/2414
:unexpected-recur {:level :off} ; TODO (cam): I think we just need to tell it how to handle MBQL match and we can enable this?
:redundant-primitive-coercion {:level :off} ; there are many defensive boolean coercions, kondo looks too much into it
:type-mismatch {:level :off} ; Still quite raw, can sometimes give useful insights, but sometimes is just confused
;;
;; globally-enabled custom linters
@@ -71,6 +72,12 @@
:metabase/namespace-name {:level :warning}
:metabase/no-jsqlparser-imports {:level :warning}
:metabase/prefer-with-dynamic-fn-redefs {:level :warning}
:metabase/t2-query-namespace {:level :warning}
:metabase/table-or-field-query {:level :warning}
;; App-DB query hygiene: a value reaching a value slot unmarked, and a query written as
;; `:column value` pairs rather than a map. Off by default and enabled per namespace under
;; `:config-in-ns`; flip it on globally once every `db.clj` has been converted.
:metabase/unsafe-app-db-query {:level :off}
:metabase/toucan-model-ns {:level :warning}
:metabase/require-shape-checker {:level :warning}
:metabase/tests-must-live-in-known-modules {:level :warning}
@@ -179,8 +186,19 @@
clojure.core.async/take!
clojure.core.async/to-chan!
clojure.core.async/to-chan!!
;; writes the ratchet files from a :once fixture that finishes before the parallel tests
dev.kondo-ratchet/fix!
;; builds Babashka's classpath cache from a :once fixture, before the parallel tests start, so that
;; they don't each race to create it; the side effect is process-external and idempotent
metabase.core.modules-consistency-test/warm-babashka-classpath!
metabase.ai-tracing.core/record!
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
;; MCP v2 tool tests: call-tool! dispatches a tool with no shared mutable state across tests
metabase.mcp.v2.tools.alert-test/call-tool!
metabase.mcp.v2.tools.definitions-test/call-tool!
metabase.mcp.v2.tools.metric-test/call-tool!
;; the `!` is for the throw (a teaching error), not a mutation; no shared mutable state
metabase.mcp.v2.tools.browse/validate-args-for-action!
metabase.channel.core/send!
metabase.channel.render.pdf/draw-too-large!
metabase.channel.render.pdf/draw-no-results!
@@ -188,6 +206,8 @@
metabase.channel.render.pdf.font/load-fonts!
metabase.warehouses.models.database/assert-not-h2!
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
;; only inspects a details map and throws; the `!` is for the throw, not for a mutation
metabase.driver/validate-db-details!
metabase.mcp.usage/record-mcp-session!
metabase.mcp.usage/record-mcp-session-end!
metabase.mcp.usage/record-mcp-tool-call!
@@ -212,7 +232,9 @@
;; Safe in tests, where we typically use a private, temporary index per thread.
metabase.search.core/reindex!
metabase.util/run-count!
next.jdbc/execute!}}
next.jdbc/execute!
;; pure validator: parses a keyword and throws on a bad one, touches no state
metabase.mcp.v2.tools.question/check-semantic-type!}}
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests
{:level :warning
@@ -275,10 +297,6 @@
instaparse.core/transform
(metabase.server.streaming-response/streaming-response)
(metabase.driver.druid.query-processor-test/druid-query-returning-rows)
(metabase.util.match/match-one)
(metabase.util.match/match-many)
(metabase.util.match/replace-in)
(metabase.util.match/replace)
(metabase.driver-api.core/match-one)
(metabase.driver-api.core/match-many)
(metabase.driver-api.core/replace)
@@ -298,7 +316,10 @@
metabase.query-processor.util/normalize-token {:namespaces ["metabase.*"]}}}
:discouraged-var
{clojure.core/print {:message "Use metabase.util.log instead of clojure.core/print"}
{metabase-enterprise.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.mirror/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
clojure.core/print {:message "Use metabase.util.log instead of clojure.core/print"}
clojure.core/println {:message "Use metabase.util.log instead of clojure.core/println"}
clojure.core/printf {:message "Use metabase.util.log instead of clojure.core/printf"}
clojure.core/prn {:message "Use metabase.util.log instead of clojure.core/prn"}
@@ -397,6 +418,7 @@
metabase.channel.render.core channel.render
metabase.cloud-migration.core cloud-migration
metabase.eid-translation.core eid-translation
metabase.llm.test-util llm.tu
metabase.login-history.core login-history
metabase.model-persistence.core model-persistence
metabase.notification.core notification
@@ -460,11 +482,14 @@
metabase-enterprise.serialization.metadata-file-import.wire-format-test/with-tiny-fixture! clojure.core/fn
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
metabase-enterprise.tenants.pulse-api-test/with-tenant-pulse-fixture! clojure.core/fn
metabase-enterprise.workspaces.test-util/with-resources! clojure.core/let
metabase-enterprise.workspaces.test-util/with-workspaces! clojure.core/let
metabase.actions.test-util/with-actions clojure.core/let
metabase.transforms.test-util/with-transform-cleanup! clojure.core/let
metabase.api.common/let-404 clojure.core/let
metabase.app-db.connection/with-unshared-connection clojure.core/fn
metabase.app-db.core/with-unshared-connection clojure.core/fn
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
metabase.app-db.custom-migrations/define-migration clj-kondo.lint-as/def-catch-all
metabase.app-db.custom-migrations/define-reversible-migration clj-kondo.lint-as/def-catch-all
@@ -482,11 +507,13 @@
metabase.driver.sql-jdbc.connection/with-connection-spec-for-testing-connection clojure.core/let
metabase.driver.sql-jdbc.execute.diagnostic/capturing-diagnostic-info clojure.core/fn
metabase.driver-api.core/defsetting hooks.metabase.settings.models.setting/defsetting
metabase.mcp.v2.registry/deftool clojure.core/defn
metabase.models.json-migration/def-json-migration clj-kondo.lint-as/def-catch-all
metabase.models.util.spec-update/define-spec clojure.core/def
metabase.notification.test-util/with-card-notification clojure.core/let
metabase.notification.test-util/with-system-event-notification! clojure.core/let
metabase.notification.test-util/with-temp-notification clojure.core/let
metabase.oauth-server.test-util/with-oauth-client clojure.core/fn
metabase.osi.ai-context.api-test/with-test-entry clojure.core/let
metabase.premium-features.core/defenterprise-schema clj-kondo.lint-as/def-catch-all
metabase.premium-features.defenterprise/defenterprise-schema clj-kondo.lint-as/def-catch-all
@@ -496,7 +523,9 @@
metabase.query-processor.pipeline-queries-test/mbql5-query clojure.core/->
metabase.query-processor.pipeline-queries-test/run-mbql5-query clojure.core/->
metabase.query-processor.error-type/deferror clojure.core/def
metabase.query-processor.core/with-reducible-deserialized-results clojure.core/let
metabase.query-processor.middleware.cache.impl/with-reducible-deserialized-results clojure.core/let
metabase.query-processor.result-serialization/with-reducible-deserialized-results clojure.core/let
metabase.query-processor.middleware.process-userland-query-test/with-query-execution! clojure.core/let
metabase.query-processor.setup/with-qp-setup clojure.core/let
metabase.search.api-test/do-test-users clojure.core/let
@@ -513,7 +542,6 @@
metabase.test.data.interface/defdataset clojure.core/def
metabase.test.data.interface/defdataset-edn clojure.core/def
metabase.test.embedded-postgres.core/with-system clojure.core/let
metabase.test.gentest/defgentest clojure.test/deftest
metabase.test/defdataset clojure.core/def
metabase.test/let-url clojure.core/let
metabase.test/with-actions clojure.core/let
@@ -524,6 +552,7 @@
metabase.test/with-temp-empty-app-db clojure.core/let
metabase.test/with-temp-file clojure.core/let
metabase.test/with-user-in-groups clojure.core/let
metabase.transforms-rest.api.transform-runs-test/with-runs-fixture! clojure.core/fn
metabase.upload.impl-test/with-upload-table! clojure.core/let
metabase.util/for-map clojure.core/for
metabase.util.files/with-open-path-to-resource clojure.core/let
@@ -566,6 +595,7 @@
clojure.core/defprotocol hooks.clojure.core.def/lint-def
clojure.core/defrecord hooks.clojure.core.def/lint-def
clojure.core/deftype hooks.clojure.core.def/lint-def
clojure.core/ex-info hooks.metabase.agent-message/lint-ex-info
clojure.core/ns hooks.clojure.core.ns/lint-ns
clojure.core/require hooks.clojure.core.require/lint-require
clojure.core/requiring-resolve hooks.clojure.core.require/lint-requiring-resolve
@@ -581,7 +611,6 @@
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.metabot.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
@@ -618,10 +647,60 @@
metabase.lib.schema.mbql-clause/define-tuple-mbql-clause hooks.metabase.lib.schema.mbql-clause/define-mbql-clause
methodical.core/defmethod hooks.metabase.toucan.table-name/lint-defmethod
methodical.macros/defmethod hooks.metabase.toucan.table-name/lint-defmethod
metabase.app-db.core/query hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.core/streaming-reducible-query hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.core/select-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.core/update-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.core/current-timestamp-string hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.query/query hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.query/streaming-reducible-query hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.query/select-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.query/update-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
metabase.app-db.query/current-timestamp-string hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/count hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/delete! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/exists? hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/insert! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/insert-returning-instance! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/insert-returning-instances! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/insert-returning-pk! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/insert-returning-pks! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/query hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/query-one hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/reducible-query hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/reducible-select hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/reducible-update hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/reducible-update-returning-pks hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/save! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-fn->fn hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-fn->pk hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-fn-reducible hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-fn-set hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-fn-vec hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-one hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-one-fn hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-one-pk hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-pk->fn hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-pks-set hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/select-pks-vec hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/update! hooks.metabase.toucan.db-ns/lint-query-call
toucan2.core/update-returning-pks! hooks.metabase.toucan.db-ns/lint-query-call
metabase.lib.test-util.macros/$ids hooks.metabase.test.data/$ids
metabase.lib.test-util.macros/mbql-query hooks.metabase.test.data/mbql-query
metabase.lib.test-util.macros/mbql-5-query hooks.metabase.test.data/mbql-query
metabase.lib.test-util.macros/with-testing-against-standard-queries hooks.metabase.lib.test-util.macros/with-testing-against-standard-queries
metabase.mcp.transport/jsonrpc-error hooks.metabase.agent-message/lint-jsonrpc-error
metabase.mcp.v2.common/ellipsize hooks.metabase.agent-message/lint-ellipsize
metabase.mcp.v2.common/error-content hooks.metabase.agent-message/lint-teaching-exit
metabase.mcp.v2.common/list-content hooks.metabase.agent-message/lint-list-content
metabase.mcp.v2.common/success-content hooks.metabase.agent-message/lint-success-content
metabase.mcp.v2.common/throw-teaching-error hooks.metabase.agent-message/lint-teaching-exit
metabase.mcp.v2.dashboard-ops/op-error! hooks.metabase.agent-message/lint-op-error
metabase.mcp.v2.message/msg hooks.metabase.agent-message/lint-msg
metabase.mcp.v2.tools.browse/paged-list-content hooks.metabase.agent-message/lint-list-content
metabase.mcp.v2.tools.parameters-test/with-fixtures hooks.common/with-one-binding
metabase.metrics.api-dimension-test/with-seeded-metric hooks.common/with-one-binding
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
@@ -670,7 +749,6 @@
metabase.test.data/dataset hooks.metabase.test.data/dataset
metabase.test.data/mbql-query hooks.metabase.test.data/mbql-query
metabase.test.data/run-mbql-query hooks.metabase.test.data/mbql-query
metabase.test.gentest/iterate hooks.metabase.test.gentest/iterate*
metabase.test.util.async/with-open-channels hooks.common/let-with-optional-value-for-last-binding
metabase.test.util.generators.jvm/with-random-cards hooks.metabase.test.util.generators.jvm/with-random-cards
metabase.test.util.log/with-log-level hooks.common/with-ignored-first-arg
@@ -721,6 +799,7 @@
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
metabase.util.match/match-one hooks.metabase.util.match/match-one
metabase.util.match/match-many hooks.metabase.util.match/match-many
metabase.util.match/matches? hooks.metabase.util.match/matches?
metabase.util.match/replace hooks.metabase.util.match/replace
metabase.util.match/replace-in hooks.metabase.util.match/replace-in
metabase.util/case-enum hooks.metabase.util/case-enum
@@ -736,6 +815,7 @@
clojurewerkz.quartzite.schedule.simple/schedule macros.quartz/simple-schedule
clojurewerkz.quartzite.triggers/build macros.quartz/build-trigger
metabase-enterprise.database-routing.e2e-test/with-temp-dbs! macros.metabase-enterprise.database-routing.e2e-test/with-temp-dbs!
metabase-enterprise.database-routing.e2e-test/with-routing-setup! macros.metabase-enterprise.database-routing.e2e-test/with-routing-setup!
metabase-enterprise.impersonation.util-test/with-impersonations! macros.metabase-enterprise.impersonation.util-test/with-impersonations!
metabase-enterprise.query-reference-validation.api-test/with-test-setup! macros.metabase-enterprise.query-reference-validation.api-test/with-test-setup!
metabase-enterprise.sandbox.test-util/with-gtaps! macros.metabase-enterprise.sandbox.test-util/with-gtaps!
@@ -788,7 +868,7 @@
:name test-namespaces}
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)(?!^metabase\\.test.*).*$"
:name source-namespaces}
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)"
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)|(?:^dev\\..*)"
:name printable-namespaces}
{:pattern "^metabase\\.(?:(?:lib)|(?:legacy-mbql))\\..*"
:name metabase-lib}
@@ -825,16 +905,40 @@
{:pattern "^metabase(?:((?:-enterprise\\.sandbox)?\\.query-processor)|(?:\\.driver\\.sql(?!server)(?!ite))).*-test$"
:name general-qp-and-driver-test-namespaces}
{:pattern "(?:^mage.*)"
:name mage-namespaces}]
:name mage-namespaces}
;;
;; the app-db module is the application database layer itself (connections, migrations, encryption), so it gets to
;; run Toucan 2 queries anywhere.
;;
{:pattern "^metabase\\.app-db\\..*"
:name app-db-namespaces}
;;
;; MCP source namespaces, OSS and enterprise, whose prose reaches an LLM agent, excluding test namespaces.
;;
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\..*"
:name agent-message-namespaces}
;;
;; MCP source namespaces whose caller-facing errors are MCP exits: `metabase.mcp.transport` and `metabase.mcp.v2.*`,
;; and their enterprise counterparts. Other MCP namespaces' 4xx `ex-info`s are admin API and middleware errors, not
;; MCP exits.
;;
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\.(?:transport$|v2\\..*)"
:name agent-exit-namespaces}]
:config-in-ns
{test-namespaces
{metabase.app-db.custom-migrations
{:linters
;; a migration reads the rows as they were when it ran; applying today's user settings would make it drift
{:metabase/table-or-field-query {:level :off}}}
test-namespaces
{:linters
{:inline-def {:level :off}
:missing-docstring {:level :off}
:private-call {:level :off}
:metabase/defsetting-must-specify-export {:level :off}
:metabase/defsetting-namespace {:level :off}
:metabase/t2-query-namespace {:level :off}
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
:discouraged-var
@@ -842,12 +946,33 @@
metabase.lib.convert/->legacy-MBQL {:level :off}
metabase.lib.core/->legacy-MBQL {:level :off}}}}
app-db-namespaces
{:linters {:metabase/t2-query-namespace {:level :off}}}
agent-message-namespaces
{:linters {:metabase/agent-message-lines {:level :warning}}}
agent-exit-namespaces
{:linters {:metabase/agent-message-exit {:level :warning}}}
;; Namespaces whose values are marked. Adding one here is what keeps it that way; see
;; `metabase.app-db.value-guard`.
metabase.content-translation.db
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
;; .cljc tests run in both Clojure and ClojureScript — `with-dynamic-fn-redefs` is JVM-only,
;; so leave these alone.
metabase.lib.aggregation-test
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
metabase.util.time-test
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
metabase.lib.metadata.calculation-test
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
;; classloader sits below `util` in the module graph, so it can't use `metabase.util.log` without creating
;; a cycle; it gets to use tools.logging directly.
metabase.classloader.impl
{:linters {:discouraged-namespace {clojure.tools.logging {:level :off}}}}
source-namespaces
{:linters

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
;; Budgets for escape hatches in config.edn. Counts may fall but not exceed these values.
;; Leave reductions to the post-merge shrink workflow; explain increases in the PR.
{:api-any 1
:friend-edges 1
:model-imports-bypass 2
:ns-prefixes 81
:uses-any 4}

View File

@@ -58,6 +58,7 @@
metabase.app-db.core/finish-db-setup!
metabase.app-db.core/migrate!
metabase.app-db.core/setup-db!
metabase.app-db.encryption-test-util/do-with-encrypted-app-db!
metabase.app-db.schema-migrations-test.impl/run-migrations-in-range!
metabase.app-db.setup/migrate!
metabase.app-db.setup/setup-db!

View File

@@ -3,3 +3,8 @@
(defmacro with-temp-dbs! [bindings & body]
`(let [~@(interleave bindings (repeat nil))]
~@body))
(defmacro with-routing-setup! [[router-binding destinations] & body]
`(let [~router-binding nil
~@(interleave (map first destinations) (repeat nil))]
~@body))

82
.clj-kondo/ratchets.edn Normal file
View File

@@ -0,0 +1,82 @@
;; Budgets for kondo suppressions: inline `:clj-kondo/ignore` forms per linter (:ignore-counts), and
;; config-level waivers in .clj-kondo/config.edn (:config-counts -- :off switches and :exclude entries).
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling.
;; Checks fail when a count exceeds its numeric budget; unused budget is allowed.
;; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one.
;; The workflow runs it on master, so feature branches do not need to record reductions.
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
;; exemptions must be explained in the PR.
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
{:ignore-counts {:aliased-namespace-symbol 3
:case-symbol-test 1
:clojure-lsp/unused-public-var 9
:consistent-alias 2
:def-fn 1
:deprecated-namespace 95
:deprecated-var 95
:discouraged-java-method 4
:discouraged-namespace 69
:discouraged-var 130
:duplicate-require 1
:equals-true 1
:main-without-gen-class 1
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-millis-duration 1
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
:metabase/modules 24
:metabase/namespace-name 4
:metabase/prefer-with-dynamic-fn-redefs :unlimited
:metabase/test-helpers-use-non-thread-safe-functions 18
:metabase/validate-defendpoint-has-response-schema 427
:metabase/validate-defendpoint-query-params-use-kebab-case 48
:metabase/validate-defendpoint-route-uses-kebab-case 44
:metabase/validate-deftest 9
:missing-docstring 12
:missing-protocol-method 3
:non-arg-vec-return-type-hint 1
:reduce-without-init 3
:redundant-do 1
:syntax 2
:type-mismatch 8
:uninitialized-var 1
:unquote-not-syntax-quoted 1
:unresolved-namespace 3
:unresolved-symbol 11
:unsorted-required-namespaces :unlimited
:unused-alias 2
:unused-binding 9
:unused-excluded-var 2
:unused-namespace 2
:unused-private-var 8
:unused-value 1}
:config-counts {:constant-condition 1
:deprecated-namespace 7
:deprecated-var 16
:discouraged-namespace 14
:discouraged-var 14
:inline-def 1
:main-without-gen-class 1
:metabase/defsetting-must-specify-export 1
:metabase/defsetting-namespace 1
:metabase/prefer-with-dynamic-fn-redefs 4
:metabase/t2-query-namespace 2
:metabase/table-or-field-query 1
:metabase/unsafe-app-db-query 1
:missing-docstring 1
:private-call 1
:redundant-ignore 1
:redundant-primitive-coercion 1
:refer-all 1
:type-mismatch 1
:unexpected-recur 1
:unresolved-symbol 15
:unresolved-var 1
:unused-referred-var 4}
:comment-exempt #{:deprecated-namespace
:discouraged-namespace
:discouraged-var
:metabase/modules
:metabase/validate-defendpoint-has-response-schema
:metabase/validate-defendpoint-query-params-use-kebab-case
:metabase/validate-defendpoint-route-uses-kebab-case}}

View File

@@ -71,10 +71,28 @@
(some-> (ns-form-node->ns-symb-node ns-form-node)
hooks/sexpr))
(defn- lint-test-parallel-metadata [ns-form-node]
(when-let [ns-symb-node (ns-form-node->ns-symb-node ns-form-node)]
(let [ns-metadata (meta (hooks/sexpr ns-symb-node))]
(cond
(:synchronous ns-metadata)
(hooks/reg-finding!
(assoc (meta ns-symb-node)
:message (str "Use `^:synchronized` to mark this namespace explicitly non-parallel; "
"`^:synchronous` is ignored by Hawk. [:metabase/validate-deftest]")
:type :metabase/validate-deftest))
(and (contains? ns-metadata :parallel)
(false? (:parallel ns-metadata)))
(hooks/reg-finding!
(assoc (meta ns-symb-node)
:message "Use `^:synchronized` instead of `^{:parallel false}`. [:metabase/validate-deftest]"
:type :metabase/validate-deftest))))))
(defn- lint-modules [ns-form-node config]
(let [ns-symb (ns-form-node->ns-symb ns-form-node)]
(when-not (modules/ignored-namespace? config ns-symb)
(when-let [current-module (modules/module ns-symb)]
(when-let [current-module (modules/module config ns-symb)]
(let [required-namespace-symb-nodes (-> ns-form-node
ns-form-node->require-node
require-node->namespace-symb-nodes)]
@@ -140,6 +158,7 @@
lint-requires-on-new-lines
(lint-modules (modules/config x))
lint-namespace-name
lint-test-parallel-metadata
lint-jsqlparser-imports)
x)

View File

@@ -11,15 +11,32 @@
(every? simple-symbol? required-namespaces)]}
(when-not (modules/ignored-namespace? config current-ns)
;; ignore namespaces outside of the module system
(when-let [current-module (modules/module current-ns)]
(when-let [current-module (modules/module config current-ns)]
(doseq [required-namespace required-namespaces]
;; ignore namespaces outside of the module system.
(when (modules/module required-namespace)
(when (modules/module config required-namespace)
(when-let [error (modules/usage-error config current-module required-namespace)]
(hooks/reg-finding! (assoc (meta node)
:message error
:type :metabase/modules))))))))
(defn- lint-dynamic-require* [node current-ns config]
(when (and (not (modules/ignored-namespace? config current-ns))
(modules/module config current-ns))
(hooks/reg-finding! (assoc (meta node)
:message "Module dependency cannot be statically determined from a dynamic require"
:type :metabase/modules))))
(defn- quoted-require? [node]
(or (= (hooks/tag node) :quote)
(and (= (hooks/tag node) :list)
(let [[x] (:children node)]
(and (hooks/token-node? x)
(= (hooks/sexpr x) 'quote))))))
(defn- keyword-node? [node]
(keyword? (hooks/sexpr node)))
(defn- unwrap-require [node]
(case (hooks/tag node)
;; some.namespace of some.namespace/some-var
@@ -42,7 +59,11 @@
;; something like [some.namespace :as whatever]
:vector
(recur (first (:children node)))))
(recur (first (:children node)))
;; anything else -- e.g. a runtime expression like `(requiring-resolve (:failing-test-var opts))` --
;; has no statically-known namespace
nil))
(comment
(unwrap-require (-> "(requiring-resolve 'metabase.notification.payload.execute/execute-dashboard)"
@@ -51,20 +72,24 @@
second)))
(defn- lint-require* [node current-ns config]
(let [[_require & args] (:children node)
required-namespaces (->> (keep unwrap-require args)
;; `require` runs through `cc/load-libs` which allows
;; #{:as :reload :reload-all :require :use :verbose :refer :as-alias}
;; as args. If you use a REPL tool that applies clj-kondo linting (e.g., Clojure MCP)
;; you may wish to `:reload` or `:reload-all` a namespace. So we shouldn't assume all
;; args are keywords:
(remove keyword?))]
(lint* node current-ns config required-namespaces)))
(let [[_require & args] (:children node)
;; Keywords such as `:reload` and `:reload-all` are options, not namespace dependencies.
namespace-nodes (remove keyword-node? args)
static-nodes (filter quoted-require? namespace-nodes)
required-namespaces (->> (keep unwrap-require static-nodes)
(filter simple-symbol?))]
(lint* node current-ns config required-namespaces)
(when (or (not= (count static-nodes) (count namespace-nodes))
(not= (count required-namespaces) (count static-nodes)))
(lint-dynamic-require* node current-ns config))))
(defn- lint-requiring-resolve* [node current-ns config]
(let [[_requiring-resolve symb-node] (:children node)
required-namespace (unwrap-require symb-node)]
(lint* node current-ns config [required-namespace])))
(if (and (quoted-require? symb-node)
(simple-symbol? required-namespace))
(lint* node current-ns config [required-namespace])
(lint-dynamic-require* node current-ns config))))
(defn lint-require [x]
(lint-require* (:node x) (:ns x) (modules/config x))

View File

@@ -28,11 +28,11 @@
(walk form)))
(defn- deftest-check-parallel
"1. Check if test is marked ^:parallel / ^:synchronized correctly.
"1. Check if test is marked `^:parallel` / `^:synchronized` correctly.
2. Make sure disallowed forms are not used in ^:parallel tests.
3. Disallow ^:parallel tests in namespaces marked ^:synchronous -- the
ns marker is the author's blanket promise that nothing inside runs
in parallel, so an individual ^:parallel deftest contradicts it."
3. Disallow `^:parallel` tests in namespaces marked `^:synchronized` --
the ns marker is the author's blanket promise that nothing inside runs
in parallel, so an individual `^:parallel` deftest contradicts it."
[{[_ test-name & body] :children, :as _node} ns-sym config]
(let [test-metadata (:meta test-name)
metadata-sexprs (map hooks/sexpr test-metadata)
@@ -44,28 +44,48 @@
(completing merge)
{}
metadata-sexprs)
parallel? (:parallel combined-metadata)
synchronized? (:synchronized combined-metadata)
ns-synchronous? (boolean (:synchronous (meta ns-sym)))]
parallel? (true? (:parallel combined-metadata))
synchronized? (true? (:synchronized combined-metadata))
explicitly-nonparallel? (or synchronized?
(and (contains? combined-metadata :parallel)
(false? (:parallel combined-metadata))))
ns-metadata (meta ns-sym)
ns-explicitly-nonparallel? (or (true? (:synchronized ns-metadata))
(and (contains? ns-metadata :parallel)
(false? (:parallel ns-metadata))))]
(when (and (contains? combined-metadata :parallel)
(false? (:parallel combined-metadata)))
(hooks/reg-finding! (assoc (meta test-name)
:message (str "Use `^:synchronized` instead of `^{:parallel false}`. "
"[:metabase/validate-deftest]")
:type :metabase/validate-deftest)))
(when (:sequential combined-metadata)
(hooks/reg-finding! (assoc (meta test-name)
:message (str "Use `^:synchronized` to mark this test explicitly non-parallel; "
"`^:sequential` is ignored by Hawk. "
"[:metabase/validate-deftest]")
:type :metabase/validate-deftest)))
(when (and parallel? synchronized?)
(hooks/reg-finding! (assoc (meta test-name)
:message "Test should not be marked both ^:parallel and ^:synchronized"
:message "Test should not be marked both `^:parallel` and `^:synchronized`"
:type :metabase/validate-deftest)))
(when (and parallel? ns-synchronous?)
(when (and parallel? ns-explicitly-nonparallel?)
(hooks/reg-finding!
(assoc (meta test-name)
:message (str "Test should not be marked ^:parallel in a namespace marked ^:synchronous -- "
:message (str "Test should not be marked `^:parallel` in a namespace marked "
"`^:synchronized` -- "
"the ns marker promises no parallel execution. "
"Drop ^:parallel from the deftest, or drop ^:synchronous from the ns. "
"Drop `^:parallel` from the deftest, or drop `^:synchronized` from the ns. "
"[:metabase/validate-deftest]")
:type :metabase/validate-deftest)))
;; only when the custom `:metabase/deftest-not-marked-parallel-or-synchronized` is enabled: complain if tests are
;; not explicitly marked `^:parallel` or `^:synchronized`. This is mostly to encourage people to mark everything
;; `^:parallel` in places like `metabase.lib` tests unless there is a really good reason not to.
(when-not (or parallel? synchronized?)
;; Only when the custom `:metabase/deftest-not-marked-parallel-or-synchronized` is enabled:
;; complain if tests are not explicitly marked `^:parallel` or `^:synchronized`. This is
;; mostly to encourage people to mark everything `^:parallel` in places like `metabase.lib`
;; tests unless there is a really good reason not to.
(when-not (or parallel? explicitly-nonparallel?)
(hooks/reg-finding!
(assoc (meta test-name)
:message "Test should be marked either ^:parallel or ^:synchronized"
:message "Test should be marked either `^:parallel` or `^:synchronized`"
:type :metabase/deftest-not-marked-parallel-or-synchronized)))
(when parallel?
(doseq [form body]
@@ -151,8 +171,11 @@
(str/starts-with? (name ns-symb) prefix))
["build-drivers."
"build."
"hooks." ; clj-kondo hook tests, outside the module system
"i18n." ; bin/i18n
"lint-migrations-file-test"
"load-namespaces." ; bin/load-namespaces
"mage."
"main-test" ; bin/release-list
"metabase.deps-edn-test"
"metabase.driver."
@@ -171,7 +194,7 @@
(not (contains? @warned-namespaces ns-symb)))
(swap! warned-namespaces conj ns-symb)
(let [known-modules (set (keys (:metabase/modules (modules/config input))))
current-module (modules/module ns-symb)]
current-module (modules/module (modules/config input) ns-symb)]
(when (or (not current-module)
(not (contains? known-modules current-module)))
(hooks/reg-finding! (assoc (meta node)
@@ -231,18 +254,20 @@
(defn use-fixtures
"Flag `:parallel/unsafe` forms inside fixtures. Skipped when the surrounding
namespace is marked `^:synchronous` -- the ns marker is the author's
namespace is marked `^:synchronized` -- the ns marker is the author's
explicit opt-in to a single-threaded test ns where destructive setup is
safe. Without that opt-in, kondo can't know whether sibling deftests will
be `^:parallel`, so the conservative default is to flag.
Sister to [[deftest-check-parallel]]: the deftest hook rejects `^:parallel`
tests inside a `^:synchronous` ns, so the two checks together form a
coherent opt-in: either the whole ns is synchronous (fixtures can be
tests inside a `^:synchronized` ns, so the two checks together form a
coherent opt-in: either the whole ns is explicitly non-parallel (fixtures can be
anything; no parallel tests allowed) or it isn't (fixtures must be
parallel-safe)."
[{:keys [node config], ns-sym :ns, :as input}]
(when-not (:synchronous (meta ns-sym))
(when-not (or (true? (:synchronized (meta ns-sym)))
(and (contains? (meta ns-sym) :parallel)
(false? (:parallel (meta ns-sym)))))
(let [linter-config (get-in config [:linters :metabase/validate-deftest])]
(doseq [form (rest (:children node))]
(warn-about-disallowed-parallel-forms form linter-config))))

View File

@@ -1,4 +1,8 @@
(ns hooks.common.modules
"Resolves namespaces to modules and enforces module boundaries.
The Kondo linter, config generator, and CI tooling share this namespace so
they apply the same rules."
(:require
[clojure.string :as str]))
@@ -12,30 +16,146 @@
(merge (get-in config [:linters :metabase/modules])
(select-keys config [:metabase/modules])))
(defn module
;;;; Module tree. These functions take the `:metabase/modules` map, or the prefix map built from it.
;;;;
;;;; Dots express nesting: `lib.schema` is a child of `lib`. A declared
;;;; `enterprise/X` module is also a child of OSS module `X`. A namespace
;;;; belongs to the module with the most specific matching `:ns-prefix`.
(defn default-ns-prefix
"Namespace prefix a module owns unless it sets `:ns-prefix`: `metabase.lib.schema` for `lib.schema`,
`metabase-enterprise.foo` for `enterprise/foo`."
[module]
(str (if (= "enterprise" (namespace module)) "metabase-enterprise." "metabase.") (name module)))
(defn module-ns-prefix
"Namespace prefix `module` owns: its `:ns-prefix`, else [[default-ns-prefix]]."
[modules module]
(or (get-in modules [module :ns-prefix])
(default-ns-prefix module)))
(defn build-prefix->module
"Map each declared namespace prefix to its module, for [[declared-module]]."
[modules]
(into {}
(map (fn [module] [(module-ns-prefix modules module) module]))
(keys modules)))
(defn declared-module
"Resolve `ns-symb` to its declared module, or `nil`.
Chooses the most specific dotted prefix and ignores a trailing `-test`."
[prefix->module ns-symb]
(loop [candidate (str/replace (str ns-symb) #"-test$" "")]
(or (get prefix->module candidate)
(when-let [dot (str/last-index-of candidate ".")]
(recur (subs candidate 0 dot))))))
(defn resolve-module
"E.g.
(module 'metabase.qp.middleware.wow) => 'qp
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
[ns-symb]
(resolve-module prefix->module 'metabase.qp.middleware.wow) => 'qp
(resolve-module prefix->module 'metabase-enterprise.whatever.core) => enterprise/whatever
An unmatched Metabase namespace resolves to its first segment so the linter can report an undeclared module."
[prefix->module ns-symb]
{:pre [(simple-symbol? ns-symb)]}
;; treat something like `metabase.driver-test` (for a module that hasn't fully been updated to use `.core`
;; namespaces) as being in the `driver` module
(let [ns-symb (if (str/ends-with? (name ns-symb) "-test")
(symbol (str/replace (name ns-symb) #"-test$" ""))
ns-symb)]
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
(or (declared-module prefix->module ns-symb)
(some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
second
(symbol "enterprise"))
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
second
symbol))))
(defn- module-api-namespaces
(defn parent-module
"The direct parent of `module`, or `nil` when it is top-level."
[modules module]
(let [module-name (name module)]
(if-let [dot (str/last-index-of module-name ".")]
(symbol (namespace module) (subs module-name 0 dot))
(when (= "enterprise" (namespace module))
(let [oss (symbol module-name)]
(when (contains? modules oss)
oss))))))
(defn descendant-of?
"Whether `module` is `ancestor` or one of its descendants."
[modules module ancestor]
(boolean (some #{ancestor} (take-while some? (iterate #(parent-module modules %) module)))))
(defn module-team
"Team owning `module`: its own `:team`, else its nearest ancestor's."
[modules module]
(some #(get-in modules [% :team])
(take-while some? (iterate #(parent-module modules %) module))))
(defn- rest-module? [module]
(re-find #"[.-]rest$" (str module)))
(defn- exports-child?
[modules parent child]
(or (contains? (set (get-in modules [parent :module-exports])) child)
;; A `.rest` child is its parent's public HTTP surface.
(rest-module? child)
;; OSS module `X` implicitly exports its `enterprise/X` companion.
(and (not= (namespace parent) (namespace child))
(contains? modules child))))
(defn- blocking-export
"The first missing export from `module` toward the root.
Returns `{:ancestor parent, :child child}`, or `nil` when every link is
exported."
[modules module]
(loop [child module]
(when-let [ancestor (parent-module modules child)]
(if (exports-child? modules ancestor child)
(recur ancestor)
{:ancestor ancestor, :child child}))))
(defn namability-error
"Explain why `caller` may not refer to `target` in `:uses`, or return `nil` if it may.
A nested module is private to the nearest ancestor with a missing export.
Exporting every link makes it public."
[modules caller target]
(when-let [{:keys [ancestor child]} (blocking-export modules target)]
(when-not (descendant-of? modules caller ancestor)
(format (str "Module %s is nested and not exported by its ancestors; %s may not use it. Add %s to %s's "
":module-exports, or move the caller into the %s subtree. [:metabase/modules %s :module-exports]")
target caller child ancestor ancestor ancestor))))
;;;; Lint rules. These functions take the full linter config.
(defonce ^:private prefix->module-cache
(atom nil))
(defn- prefix->module
"[[build-prefix->module]] for `config`, cached because Kondo hands every hook call the same `:metabase/modules` map."
[config]
(let [modules (:metabase/modules config)
cached @prefix->module-cache]
(if (identical? modules (:modules cached))
(:prefix->module cached)
(:prefix->module (reset! prefix->module-cache {:modules modules
:prefix->module (build-prefix->module modules)})))))
(defn module
"Resolve `ns-symb` to its module under `config`; see [[resolve-module]]."
[config ns-symb]
(resolve-module (prefix->module config) ns-symb))
(defn module-api-namespaces
"Set of API namespace symbols for a given module. `:any` means you can use anything, there are no API namespaces for
this module (yet). If unspecified, the default is just the `<module>.core` namespace."
[config module]
(let [module-config (get-in config [:metabase/modules module :api])]
this module (yet). If unspecified, the defaults are `<prefix>.api`, `<prefix>.core`, and `<prefix>.init`."
[modules module]
(let [module-config (get-in modules [module :api])]
(cond
(= module-config :any)
nil
@@ -44,9 +164,7 @@
module-config
:else
(let [ns-prefix (if (= (namespace module) "enterprise")
(str "metabase-enterprise." (name module))
(name module))]
(let [ns-prefix (module-ns-prefix modules module)]
#{(symbol (str ns-prefix ".api"))
(symbol (str ns-prefix ".core"))
(symbol (str ns-prefix ".init"))}))))
@@ -68,15 +186,14 @@
(contains? (set allowed-modules) required-module))))
(defn- allowed-module-namespace? [config current-module ns-symb]
(let [module (module ns-symb)
module-api-namespaces (module-api-namespaces config module)
(let [module (module config ns-symb)
module-api-namespaces (module-api-namespaces (:metabase/modules config) module)
module-friends (module-friends config module)]
(or (empty? module-api-namespaces)
(or (nil? module-api-namespaces)
(contains? module-api-namespaces ns-symb)
(contains? module-friends current-module))))
(defn- rest-module? [module]
(str/ends-with? module "-rest"))
(contains? module-friends current-module)
;; a child may use its ancestors' internals; a parent still goes through its child's `:api`
(descendant-of? (:metabase/modules config) current-module module))))
(defn- routes-module? [module]
(str/ends-with? module "-routes"))
@@ -89,7 +206,7 @@
error type if there is one, otherwise `nil` if there are no errors."
[config current-module required-namespace]
;; ignore stuff not in a module i.e. non-Metabase stuff.
(when-let [required-module (module required-namespace)]
(when-let [required-module (module config required-namespace)]
(when-not (= current-module required-module)
(cond
(not (allowed-module? config current-module required-module))
@@ -113,4 +230,8 @@
required-module
current-module
required-module
(symbol (str/replace required-module #"-rest$" "")))))))
(symbol (str/replace required-module #"[.-]rest$" "")))
;; Config tests check explicit `:uses`; wildcard callers are checked here.
(= :any (allowed-modules config current-module))
(namability-error (:metabase/modules config) current-module required-module)))))

View File

@@ -0,0 +1,375 @@
(ns hooks.metabase.agent-message
"Hooks linting agent-facing prose built with `metabase.mcp.v2.message/msg` (`:metabase/agent-message-lines`) and the
exits and helpers that carry it to the agent (`:metabase/agent-message-exit`). Each hook checks only when its
linter's level is configured and not `:off`, and returns its input unchanged."
(:require
[clj-kondo.hooks-api :as hooks]
[clojure.string :as str]))
(defn- level-on?
[config linter-key]
(not= :off (get-in config [:linters linter-key :level] :off)))
(defn- call-name
"The symbol at the head of list `node`, or nil."
[node]
(when (hooks/list-node? node)
(let [head (first (:children node))]
(when (hooks/token-node? head)
(let [v (hooks/sexpr head)]
(when (symbol? v) v))))))
(defn- calls?
"Whether `node` is a call to a var named `var-name` in one of `ns-names` (nil for an unqualified call)."
[node ns-names var-name]
(when-let [sym (call-name node)]
(and (= var-name (name sym))
(contains? ns-names (namespace sym)))))
(def ^:private core-ns #{nil "clojure.core"})
(def ^:private string-ns #{"str" "string" "clojure.string"})
(defn- literal-string
"The string value of `node` when it is a string literal or a `str` call over string literals, else nil."
[node]
(cond
(hooks/string-node? node)
(hooks/sexpr node)
(and (calls? node core-ns "str")
(every? hooks/string-node? (rest (:children node))))
(apply str (map hooks/sexpr (rest (:children node))))))
(defn- digits->long
"The value of a string of decimal digits."
[s]
;; Kondo's hook interpreter has no `parse-long`.
(reduce (fn [n c] (+ (* 10 n) (- (int c) (int \0)))) 0 s))
(def ^:private format-specifier
"A `java.util.Formatter` specifier; groups are explicit index, flags, width, precision, and conversion. The index is
capped at nine digits, so a longer one reads as malformed rather than a number too large to work with."
#"%(?:(\d{1,9})\$)?([-#+ 0,(<]*)(\d+)?(?:\.(\d+))?([tT]?[a-zA-Z%])")
(defn- consumed-args
"`[index conversion]` for each argument-consuming specifier in `fmt`, with zero-based argument indexes. An index of
-1 marks a reference no argument can satisfy: an explicit `0$`, or a `<` with no specifier before it to repeat."
[fmt]
(loop [[[_ explicit flags _width _precision conversion] & more] (re-seq format-specifier fmt)
next-index 0
last-index nil
acc []]
(if-not conversion
acc
(let [consumes? (not (contains? #{"%" "n"} conversion))
index (cond
(not consumes?) nil
explicit (dec (digits->long explicit))
(str/includes? flags "<") (or last-index -1)
:else next-index)
implicit? (and consumes? (not explicit) (not (str/includes? flags "<")))]
(recur more
(cond-> next-index implicit? inc)
(or index last-index)
(cond-> acc
index (conj [index conversion])))))))
(def ^:private msg-linter :metabase/agent-message-lines)
(defn- reg-msg-finding!
[node message]
(hooks/reg-finding! (assoc (meta node) :message message :type msg-linter)))
(defn- line-break?
"Whether format string `line` contains a line-breaking, control, or private-use character, or a `%n` specifier."
[line]
;; Private-use characters delimit the argument markers `render` formats with, so a line holding one could fake one.
(boolean (or (re-find #"[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]" line)
(some #(= "n" (nth % 5)) (re-seq format-specifier line)))))
(defn- allowed-specifier?
"Whether specifier match `specifier` renders its value as `render` passes it: a bare `%%`, a `%s` with no flags but
`<`, or a `%d` with any flags and width but no precision. An explicit argument index is allowed on each."
[[_ explicit flags width precision conversion]]
(case conversion
"%" (not (or explicit (seq flags) width precision))
"s" (not (or width precision (seq (str/replace flags "<" ""))))
"d" (not precision)
false))
(defn- disallowed-specifier
"The first specifier in format string `line` that isn't [[allowed-specifier?]], as it is written, or nil."
[line]
(some #(when-not (allowed-specifier? %) (first %)) (re-seq format-specifier line)))
(def ^:private valid-conversion
"The conversions `java.util.Formatter` accepts, with each date/time conversion's required suffix."
#"[bBhHsScCdoxXeEfgGaA%n]|[tT][HIklMSLNpzZsQBbhAaCYyjmdeRTrDFc]")
(defn- malformed-specifier?
"Whether format string `line` has a `%` that doesn't begin a valid `java.util.Formatter` specifier."
[line]
(let [valid-removed (str/replace line format-specifier
(fn [[specifier & groups]]
(if (re-matches valid-conversion (last groups)) "" specifier)))]
(str/includes? valid-removed "%")))
(defn- line-problem
"The finding message for the first problem with a `msg` line, given its literal string or nil; nil when it has none."
[line]
(cond
(nil? line)
(str "Each line of a `msg` must be a string literal or a `str` of string literals; "
"pass values as arguments after the vector.")
(line-break? line)
(str "A `msg` line can't contain a line break, `%n`, or a control or private-use character; "
"put each line in its own string.")
(malformed-specifier? line)
"A `%` in a `msg` line must begin a format specifier; write a literal percent sign as `%%`."
:else
(when-let [specifier (disallowed-specifier line)]
(str "A `msg` line may only use `%s`, `%d`, or `%%`; `" specifier "` would alter or hide the value."))))
(defn- lint-lines!
"Flag each of `line-nodes` whose literal string, the matching element of `lines`, has a problem."
[line-nodes lines]
(doseq [[line-node line] (map vector line-nodes lines)]
(when-let [problem (line-problem line)]
(reg-msg-finding! line-node problem))))
(defn- plural
[n word]
(str n " " word (when-not (= 1 n) "s")))
(defn- lint-arg-count!
"Flag `msg` call `node` when a literal line refers to an argument that can't exist, or when its `args` don't number
the arguments its literal `lines` consume."
[node lines args]
(let [indexes (map first (consumed-args (str/join "\n" lines)))
expected (if (seq indexes) (inc (apply max indexes)) 0)
given (count args)]
(cond
;; `String/format` throws on these, so the message would render with every part quoted instead.
(some neg? indexes)
(reg-msg-finding! node (str "A `msg` line refers to an argument that can't exist; "
"argument indexes start at `1$`, and `%<` repeats the specifier before it."))
(not= expected given)
(reg-msg-finding! node (format "The `msg` lines take %s but %d %s given."
(plural expected "argument") given (if (= 1 given) "is" "are"))))))
(defn lint-msg
"Flag `msg` calls whose lines aren't a literal vector of single-line literal strings, that hold a malformed
specifier or a conversion other than `%s`, `%d`, or `%%`, or whose arguments don't match the lines' specifiers."
[{:keys [node config] :as input}]
(when (level-on? config msg-linter)
(let [[fn-node lines-node & args] (:children node)]
(if-not (and lines-node (hooks/vector-node? lines-node))
(reg-msg-finding! (or lines-node fn-node)
"`msg` takes a literal vector of line strings, one string per line of the message.")
(let [line-nodes (:children lines-node)
lines (map literal-string line-nodes)]
(lint-lines! line-nodes lines)
;; A malformed specifier can't be counted, and its line already has a finding.
(when (and (every? some? lines) (not-any? malformed-specifier? lines))
(lint-arg-count! node lines args))))))
input)
(def ^:private exit-linter :metabase/agent-message-exit)
(defn- exit-enabled?
[{:keys [config]}]
(level-on? config exit-linter))
(defn- reg-exit-finding!
[node message]
(hooks/reg-finding! (assoc (meta node) :message message :type exit-linter)))
(defn- calls-core?
"Whether `node` is a call to a `clojure.core` var or special form named in `var-names`."
[node var-names]
(when-let [sym (call-name node)]
(and (contains? var-names (name sym))
(contains? core-ns (namespace sym)))))
(defn- calls-i18n?
[node]
(when-let [sym (call-name node)]
(contains? #{"tru" "trs" "deferred-tru" "deferred-trs"} (name sym))))
(defn- calls-json-encode?
"Whether `node` calls `encode` in a namespace or alias whose name ends in `json`."
[node]
(when-let [sym (call-name node)]
(and (= "encode" (name sym))
(boolean (some-> (namespace sym) (str/ends-with? "json"))))))
(defn- builds-string?
"Whether `node` is a string literal or a call that builds a string."
[node]
(or (hooks/string-node? node)
(calls-core? node #{"str" "format" "pr-str" "ex-message"})
(calls-i18n? node)
(calls? node string-ns "join")
(calls-json-encode? node)))
(defn- clause-results
"The result nodes of `clauses`, alternating test and result nodes, with a trailing default when their count is odd."
[clauses]
(concat (take-nth 2 (rest clauses))
(when (odd? (count clauses)) [(last clauses)])))
(defn- result-fn-marker?
[node]
(and (hooks/keyword-node? node) (= :>> (hooks/sexpr node))))
(defn- condp-results
"The result nodes of `condp` `clauses`: each test's result, skipping `:>>` result functions, and any default."
[clauses]
(loop [[_test result & more :as clauses] clauses
acc []]
(cond
(empty? clauses) acc
(nil? (next clauses)) (conj acc (first clauses))
(result-fn-marker? result) (recur (next more) acc)
:else (recur more (conj acc result)))))
(defn- branch-results
"The nodes branching form `node` can evaluate to, or nil when it isn't one."
[node]
(let [args (rest (:children node))]
(cond
(calls-core? node #{"if" "if-not" "if-let"}) (rest args)
(calls-core? node #{"or"}) args
(calls-core? node #{"cond"}) (clause-results args)
(calls-core? node #{"case"}) (clause-results (rest args))
(calls-core? node #{"condp"}) (condp-results (drop 2 args)))))
(defn- step-call
"Threading step `node` as the call it stands for: a list as is, anything else as a call with no written arguments."
[node]
(if (hooks/list-node? node) node (hooks/list-node [node])))
(defn- threading-results
"The nodes whose text-ness decides what threading form `node` evaluates to, or nil when it isn't one."
[node]
(let [[init & steps] (rest (:children node))]
(cond
(calls-core? node #{"->" "->>" "some->" "some->>"}) [(if steps (step-call (last steps)) init)]
(calls-core? node #{"cond->" "cond->>"}) (cons init (map step-call (take-nth 2 (rest steps))))
(calls-core? node #{"as->"}) [(or (last (rest steps)) init)]
(calls-core? node #{"doto"}) [init])))
(defn- tail-result
"The node body form `node` evaluates to, or nil when it isn't one."
[node]
(let [args (rest (:children node))]
(cond
(calls-core? node #{"when" "when-not" "when-let" "let"}) (when (next args) (last args))
(calls-core? node #{"do"}) (last args))))
(defn- stringy?
"Whether `node` syntactically evaluates to text: a string literal, a call that builds a string, or a threading,
branching, or body form whose result is one."
[node]
(boolean
(or (builds-string? node)
(some stringy? (branch-results node))
(some stringy? (threading-results node))
(some-> (tail-result node) stringy?))))
(defn- lint-exit-text!
"Flag `arg`, the message passed to `target` (a description of where it goes), when it is text rather than a `msg`."
[arg target]
(when (and arg (stringy? arg))
(reg-exit-finding! arg (format (str "`%s` passes text to %s; "
"build agent-facing text with `msg` so interpolated values are cleaned.")
(pr-str (hooks/sexpr arg))
target))))
(defn- lint-exit-arg!
"Flag the argument at child `index` of call `node` when it is text rather than a `msg`."
[node index]
(lint-exit-text! (nth (:children node) index nil) (str "`" (name (call-name node)) "`")))
(defn lint-teaching-exit
"Flag a call throwing or returning a teaching error whose message, the first argument, is text rather than a `msg`."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(lint-exit-arg! node 1))
input)
(defn lint-jsonrpc-error
"Flag a `jsonrpc-error` whose message, the third argument, is text rather than a `msg`."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(lint-exit-arg! node 3))
input)
(defn lint-op-error
"Flag an `op-error!` whose detail message, the third argument, is text rather than a `msg`."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(lint-exit-arg! node 3))
input)
(defn lint-ellipsize
"Flag an `ellipsize` whose message, the first argument, is text rather than a `msg`."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(lint-exit-arg! node 1))
input)
(defn- map-value
"The value node of keyword `k` in map literal `node`, or nil."
[node k]
(when (hooks/map-node? node)
(some (fn [[key-node value-node]]
(when (and (hooks/keyword-node? key-node) (= k (hooks/sexpr key-node)))
value-node))
(partition 2 (:children node)))))
(defn lint-list-content
"Flag a `list-content`, or a wrapper taking its options map as the third argument too, whose literal options map
has an `:empty-hint` that is text rather than a `msg`."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(lint-exit-text! (map-value (nth (:children node) 3 nil) :empty-hint)
(str "`" (name (call-name node)) "`'s `:empty-hint`")))
input)
(defn lint-success-content
"Flag a `success-content` whose first argument is text rather than a payload or a `msg`."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(let [text (second (:children node))]
(when (and text (stringy? text))
(reg-exit-finding! text (str "`success-content` takes data to JSON-encode or a `msg`; "
"pass the payload itself, or build the text with `msg`.")))))
input)
(defn- caller-facing-data?
"Whether `node` is a map literal with a 4xx `:status-code` literal or an `error-code` key."
[node]
(and (hooks/map-node? node)
(some (fn [[k v]]
(when (hooks/keyword-node? k)
(let [kw (hooks/sexpr k)]
(or (= "error-code" (name kw))
(and (= :status-code kw)
(hooks/token-node? v)
(let [status (hooks/sexpr v)]
(and (number? status) (<= 400 status 499))))))))
(partition 2 (:children node)))))
(defn lint-ex-info
"Flag an `ex-info` whose data marks it as a caller-facing error."
[{:keys [node] :as input}]
(when (exit-enabled? input)
(when (caller-facing-data? (nth (:children node) 2 nil))
(reg-exit-finding! node (str "Caller-facing errors must be thrown with `throw-teaching-error` and a `msg`, "
"so their text is rendered and cleaned."))))
input)

View File

@@ -4,9 +4,9 @@
[hooks.common :as common]))
(defn lint-defsetting-namespace [node context]
(when-not (re-matches #"^metabase(?:-enterprise)?\.[^\.]+\.settings$" (name (:ns context)))
(when-not (re-matches #"^metabase(?:-enterprise)?\.([^\.]+\.)+settings$" (name (:ns context)))
(hooks/reg-finding! (assoc (meta node)
:message "All defsettings should live in metabase[-enterprise].<module>.settings namespaces"
:message "All defsettings should live in a namespace ending in .settings under metabase[-enterprise]"
:type :metabase/defsetting-namespace))))
;;; TODO -- move this into a Kondo config file in `.clj-kondo/config/`
@@ -286,6 +286,7 @@
(update context :node update-node)))
(comment
(require '[clojure.pprint])
(defn- defsetting* [form]
(hooks/sexpr
(:node
@@ -293,7 +294,6 @@
{:node
(hooks/parse-string
(with-out-str
#_{:clj-kondo/ignore [:unresolved-namespace]}
(clojure.pprint/pprint
form)))}))))

View File

@@ -1,14 +0,0 @@
(ns hooks.metabase.test.gentest
(:require
[clj-kondo.hooks-api :as hooks]))
(defn iterate*
"(iterate {:as limit-spec} [s1 val1 & more] body-form body-form...)"
[{{:keys [children] :as node} :node :as orig}]
(let [[token limit-spec bindings & body-nodes] children
node* (-> (hooks/list-node
(list token limit-spec
(hooks/list-node (list* (hooks/token-node 'let) bindings
body-nodes))))
(with-meta (meta node)))]
(assoc orig :node node*)))

View File

@@ -0,0 +1,146 @@
(ns hooks.metabase.toucan.db-ns
"Lint that application database query calls -- Toucan 2's `t2/select`, `t2/query`, `t2/insert!`, `t2/update!`,
`t2/delete!` and friends, plus the `metabase.app-db.core` wrappers around them such as `mdb/query` and
`mdb/update-or-insert!` -- live in a module's `db` namespace, i.e. `metabase[-enterprise].<module>.db` (or `metabase.driver.<driver>.db` for
driver modules). Every other namespace in a module goes through those functions instead of talking to the
application database directly.
Registered as an `:analyze-call` hook on each of those functions in `.clj-kondo/config.edn`. The hook
returns its input unchanged so Kondo's normal analysis of the call (arity, var usage) still runs."
(:require
[clj-kondo.hooks-api :as hooks]
[hooks.common.modules :as modules]))
(def ^:private driver-db-namespace
"Matches `metabase.driver.<driver>.db`; individual drivers are not modules."
#"^metabase\.driver\.[^.]+\.db$")
(defn- db-namespace? [config ns-sym]
(let [ns-str (name ns-sym)]
(boolean
;; Resolve ownership first: a nested `.db` name alone does not make a module.
(or (when-let [module (modules/module config ns-sym)]
(= ns-str (str (modules/module-ns-prefix (:metabase/modules config) module) ".db")))
(re-matches driver-db-namespace ns-str)))))
(defn- test-file?
"Whether `filename` is in a test source tree. Test namespaces are exempt through the `test-namespaces` group in
`config.edn`, but helper namespaces like `metabase.sso.test-helpers` don't match that group's pattern."
[filename]
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
(def ^:private value-operators
"Operators whose second argument is a value rather than a column."
'#{= not= < > <= >= like not-like ilike not-ilike in not-in between})
(defn- marked?
"Whether `node` is an `[:auto/param v]` marker."
[node]
(and (hooks/vector-node? node)
(= :auto/param (some-> (first (:children node)) hooks/sexpr))))
(defn- value-nodes
"The nodes sitting in a value slot of `node`, following the clause shapes a query map uses."
[node]
(cond
;; A query map -- walk its clause values, not its keys.
(hooks/map-node? node)
(mapcat value-nodes (take-nth 2 (rest (:children node))))
(hooks/vector-node? node)
(let [[head & args] (:children node)
op (some-> head hooks/sexpr)]
(cond
(contains? #{:and :or :not} op)
(mapcat value-nodes args)
;; `[:= col v]`, `[:in col vs]`, `[:between col lo hi]` -- the first argument is the
;; column and the rest are values, whatever the arity.
(and (keyword? op) (contains? value-operators (symbol (name op))))
(rest args)
:else
(mapcat value-nodes (:children node))))
:else nil))
(def ^:private write-fns
"Calls whose values are written rather than filtered on. A value here is not a where-clause value
an attacker can turn into SQL structure, and a `define-before-insert` hook may read it before the
query compiles, so a marker would break it rather than protect anything."
'#{insert! insert-returning-instance! insert-returning-instances! insert-returning-pk!
insert-returning-pks! save!})
(defn- read-call?
"Whether `node` is a query call whose values are filtered on rather than written."
[node]
(let [f (some-> (first (:children node)) hooks/sexpr)]
(not (contains? write-fns (some-> f name symbol)))))
(defn- kv-arg-pair
"The first `:column value` pair a query call passes after its model, if any.
`(t2/select :model/X :locale locale)`. Several of these fns take an argument before the model --
`(t2/select-one-fn :value :model/X :key k)` -- so the pairs do not start at a fixed offset; they
start after the `:model/...` keyword. A call that does not name a literal model is not checked."
[args]
(let [after-model (->> args
(drop-while #(not (and (hooks/keyword-node? %)
(= "model" (namespace (hooks/sexpr %))))))
rest)
[k v] after-model]
(when (and k v (hooks/keyword-node? k))
k)))
(defn- lint-kv-args!
"Register a finding for a query written as `:column value` pairs.
Toucan builds those into the where clause itself, so the query's shape is not on the page: there
is nothing for a reader, this linter, or the `.sql` extraction that follows this project to read.
The same call takes a map -- `{:where [:= :locale v]}` for a select, `{:locale v}` for an
`update!` -- which is the statement, near enough to read off."
[node]
(when-let [k (kv-arg-pair (rest (:children node)))]
(hooks/reg-finding!
(assoc (meta k)
:message (format "Pass this query a map rather than `%s ...` pairs, so its shape is on the page."
(hooks/sexpr k))
:type :metabase/unsafe-app-db-query))))
(defn- lint-unmarked-values!
"Register a finding for each argument of the enclosing function that reaches a value slot unmarked.
Only a symbol is reported. A literal cannot carry a request value, and a value built inside the
function is out of reach of a check that does not follow it across a call."
[node]
(doseq [value (mapcat value-nodes (rest (:children node)))
:when (and (hooks/token-node? value)
(symbol? (hooks/sexpr value))
(not (marked? value)))]
(hooks/reg-finding!
(assoc (meta value)
:message (format "`%s` reaches a SQL value slot unmarked. Write it as [:auto/param %s] so it is bound as a parameter."
(hooks/sexpr value) (hooks/sexpr value))
:type :metabase/unsafe-app-db-query))))
(defn lint-query-call
"Register a `:metabase/t2-query-namespace` finding when a Toucan 2 query call appears outside a `<module>.db`
namespace."
[{:keys [node ns filename] :as input}]
(when (and ns
(not (db-namespace? (modules/config input) ns))
(not (test-file? filename)))
(let [fn-node (first (:children node))]
(hooks/reg-finding!
(assoc (meta fn-node)
:message (format "Application database query calls like `%s` must live in metabase[-enterprise].<module>.db (or metabase.driver.<driver>.db) namespaces"
(hooks/sexpr fn-node))
:type :metabase/t2-query-namespace))))
((requiring-resolve 'hooks.metabase.warehouse-schema-overlay.table-or-field-query/lint-read) input)
(when (and ns
(db-namespace? (modules/config input) ns)
(not (test-file? filename))
(read-call? node))
(lint-unmarked-values! node)
(lint-kv-args! node))
input)

View File

@@ -124,6 +124,25 @@
(concat [(api/token-node 'clojure.core/or)] result-exprs [opaque-node]))))]
{:node new-node})))
(defn matches?
"clj-kondo :analyze-call hook for matches?. Similar to `match-one`, but supports just a single clause with no result."
[{:keys [node]}]
(let [[_ value pat] (:children node)]
(let [all-bindings (->> (collect-bindings pat)
dedupe-bindings)
special-bindings [(api/token-node '_) value
(api/token-node '&match) opaque-node
;; Pretend to use &match so that Kondo doesn't complain about it.
(api/token-node '_) (api/token-node '&match)]
binding-nodes (into special-bindings all-bindings)
new-node (api/list-node
(list
(api/token-node 'let)
(api/vector-node binding-nodes)
(api/list-node
(list opaque-node))))]
{:node new-node})))
(def match-many match-one)
(def replace match-one)

View File

@@ -0,0 +1,52 @@
(ns hooks.metabase.warehouse-schema-overlay.table-or-field-query
"Lint that a query over `:model/Field` or `:model/Table` names its source with `field-query`/`table-query` from
`metabase.warehouse-schema-overlay.core`, or reads sync's values with `{:user-settings? false}`."
(:require
[clj-kondo.hooks-api :as hooks]))
(def ^:private read-fn?
"The Toucan 2 query functions that read rows. Writes are not affected: they go to `metabase_field` itself."
#{"select" "select-one" "select-fn->fn" "select-fn->pk" "select-fn-reducible" "select-fn-set" "select-fn-vec"
"select-one-fn" "select-one-pk" "select-pk->fn" "select-pks-set" "select-pks-vec" "select-reducible"
"reducible-select" "reducible-query" "query" "query-one" "count" "exists?"})
(def ^:private model->helper
{":model/Field" "field-query"
":model/Table" "table-query"})
(defn- test-file? [filename]
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
(defn- tokens
"Every token in `node`'s tree, as strings."
[node]
(tree-seq :children :children node))
(defn- mentions?
[node pred]
(boolean (some (fn [n] (when-let [s (some-> (hooks/sexpr n) pr-str)] (pred s)))
(filter (complement :children) (tokens node)))))
(defn- writes?
"Whether a `t2/query` form is really a write spelled as a query."
[node]
(mentions? node #{":update" ":delete-from" ":insert-into"}))
(defn lint-read
"Register a `:metabase/table-or-field-query` finding when a read of `:model/Field` or `:model/Table` does not name
its source with the matching helper."
[{:keys [node filename]}]
(let [fn-node (first (:children node))
fn-name (some-> (hooks/sexpr fn-node) name)]
(when (and fn-name
(read-fn? fn-name)
(not (test-file? filename))
(not (writes? node)))
(doseq [[model helper] model->helper
:when (mentions? node #(= % model))
:when (not (mentions? node #(re-find (re-pattern (str "\\b" helper "$")) %)))]
(hooks/reg-finding!
(assoc (meta fn-node)
:message (format "A read of %s must name its source: add {:from [(%s)]} (or {:user-settings? false} for sync's own values)"
model helper)
:type :metabase/table-or-field-query))))))

View File

@@ -19,6 +19,19 @@
(defn- lint-modules [form config]
(lint-ns form (assoc-in config [:linters :metabase/modules :level] :warning)))
(deftest ^:parallel explicit-nonparallel-namespace-metadata-test
(let [config {:linters {:metabase/validate-deftest {:level :warning}}}]
(testing "Hawk's supported namespace marker passes"
(is (empty? (lint-ns '(ns ^:synchronized example.test) config))))
(testing "the verbose Hawk spelling is not accepted as Metabase style"
(is (=? [{:type :metabase/validate-deftest
:message #(re-find #"Use `\^:synchronized` instead" %)}]
(lint-ns '(ns ^{:parallel false} example.test) config))))
(testing "the legacy marker explains that Hawk ignores it"
(is (=? [{:type :metabase/validate-deftest
:message #(re-find #"`\^:synchronous` is ignored by Hawk" %)}]
(lint-ns '(ns ^:synchronous example.test) config))))))
(deftest ^:parallel module-checker-allowed-modules-test
(are [task-namespace] (=? [{:message "Module task should not be used in the api module. [:metabase/modules api :uses]"
:type :metabase/modules}]
@@ -43,6 +56,16 @@
'{:metabase/modules {api {:uses #{search}}
search {:api #{metabase.search.core}}}}))))
(deftest ^:parallel module-checker-empty-api-test
(is (=? [{:message "Namespace metabase.embeddings.core is not an allowed external API namespace for the embeddings module. [:metabase/modules embeddings :api]"
:type :metabase/modules}]
(lint-modules
'(ns metabase.metabot.core
(:require
[metabase.embeddings.core :as embeddings]))
'{:metabase/modules {metabot {:uses #{embeddings}}
embeddings {:api #{}}}}))))
(deftest ^:parallel module-checker-friends-test
(is (= []
(lint-modules

View File

@@ -0,0 +1,65 @@
(ns hooks.clojure.core.require-test
(:require
[clj-kondo.hooks-api :as hooks]
[clj-kondo.impl.utils]
[clojure.test :refer :all]
[hooks.clojure.core.require]))
(def ^:private config
'{:linters {:metabase/modules {:level :warning}}
:metabase/modules {search {:uses #{}}
task {:api #{metabase.task.core}}}})
(defn- lint [hook form current-ns]
(binding [clj-kondo.impl.utils/*ctx* {:config config
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(hook {:node (hooks/parse-string (pr-str form))
:ns current-ns
:config config})
@(:findings clj-kondo.impl.utils/*ctx*)))
(defn- lint-require [form current-ns]
(lint hooks.clojure.core.require/lint-require form current-ns))
(defn- lint-requiring-resolve [form current-ns]
(lint hooks.clojure.core.require/lint-requiring-resolve form current-ns))
(def ^:private dynamic-require-finding
{:message "Module dependency cannot be statically determined from a dynamic require"
:type :metabase/modules})
(def ^:private cross-module-finding
{:message "Module task should not be used in the search module. [:metabase/modules search :uses]"
:type :metabase/modules})
(deftest ^:parallel dynamic-requiring-resolve-test
(testing "outside the module system"
(is (= [] (lint-requiring-resolve '(requiring-resolve (:failing-test-var opts)) 'dev))))
(testing "inside a module"
(are [form] (=? [dynamic-require-finding]
(lint-requiring-resolve form 'metabase.search.core))
'(requiring-resolve (:failing-test-var opts))
'(requiring-resolve @some-atom)
'(requiring-resolve some-local)
'(requiring-resolve "not-a-symbol"))))
(deftest ^:parallel dynamic-require-test
(testing "outside the module system"
(is (= [] (lint-require '(require some-local) 'dev))))
(testing "inside a module"
(is (=? [dynamic-require-finding]
(lint-require '(require some-local) 'metabase.search.core)))))
(deftest ^:parallel static-requiring-resolve-still-linted-test
(is (=? [cross-module-finding]
(lint-requiring-resolve '(requiring-resolve 'metabase.task.impl/go)
'metabase.search.core))
"a statically-known cross-module violation is still reported"))
(deftest ^:parallel static-require-still-linted-test
(is (=? [cross-module-finding]
(lint-require '(require 'metabase.task.impl :reload)
'metabase.search.core))
"a statically-known cross-module violation is still reported while keyword flags are ignored"))

View File

@@ -21,6 +21,54 @@
#{:athena}}}}})
(mapv :message @(:findings clj-kondo.impl.utils/*ctx*))))
(defn- parallel-metadata-findings
([form]
(parallel-metadata-findings form 'example.test))
([form ns-sym]
(let [linters {:metabase/deftest-not-marked-parallel-or-synchronized {:level :warning}
:metabase/validate-deftest {:level :warning}}]
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters linters}
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(hooks.clojure.test/deftest {:node (api/parse-string form)
:lang :clj
:ns ns-sym
:config {:linters {:metabase/validate-deftest {}}}})
@(:findings clj-kondo.impl.utils/*ctx*)))))
(deftest ^:parallel explicit-nonparallel-metadata-test
(testing "Hawk's supported test markers satisfy the explicit metadata linter"
(are [metadata] (empty? (parallel-metadata-findings
(format "(deftest %s example-test (is true))" metadata)))
"^:parallel"
"^:synchronized"))
(testing "an unmarked test still gets the opt-in linter's guidance"
(is (=? [{:type :metabase/deftest-not-marked-parallel-or-synchronized
:message "Test should be marked either `^:parallel` or `^:synchronized`"}]
(parallel-metadata-findings "(deftest example-test (is true))"))))
(testing "the unsupported marker explains that Hawk ignores it"
(is (some #(and (= (:type %) :metabase/validate-deftest)
(re-find #"ignored by Hawk" (:message %)))
(parallel-metadata-findings
"(deftest ^:sequential example-test (is true))"))))
(testing "the verbose Hawk spelling is not accepted as Metabase style"
(is (some #(and (= (:type %) :metabase/validate-deftest)
(re-find #"Use `\^:synchronized` instead" (:message %)))
(parallel-metadata-findings
"(deftest ^{:parallel false} example-test (is true))"))))
(testing "a test cannot request both execution modes"
(is (some #(and (= (:type %) :metabase/validate-deftest)
(re-find #"both `\^:parallel` and `\^:synchronized`" (:message %)))
(parallel-metadata-findings
"(deftest ^:parallel ^:synchronized example-test (is true))"))))
(testing "a namespace-level non-parallel promise rejects parallel tests"
(is (=? [{:type :metabase/validate-deftest
:message #(re-find #"namespace marked.*`\^:synchronized`" %)}]
(parallel-metadata-findings
"(deftest ^:parallel example-test (is true))"
(with-meta 'example.test {:synchronized true}))))))
(deftest ^:parallel disallow-hardcoded-driver-names-in-tests-test
(is (= []
(deftest-warnings

View File

@@ -5,6 +5,6 @@
(deftest ^:parallel module-test
(are [symb expected] (= expected
(hooks.common.modules/module symb))
(hooks.common.modules/module {} symb))
'metabase.qp.middleware.wow 'qp
'metabase-enterprise.whatever.core 'enterprise/whatever))

View File

@@ -0,0 +1,334 @@
(ns hooks.metabase.agent-message-test
(:require
[clj-kondo.hooks-api :as hooks]
[clj-kondo.impl.utils]
[clojure.edn :as edn]
[clojure.test :refer :all]
[hooks.metabase.agent-message :as agent-message]))
(defn- findings
"Run `hook-fn` over `form`, a form or its source string, with the linters at `level` (unconfigured when nil) in
namespace `ns` of file `filename`; return the findings' messages."
([hook-fn form]
(findings hook-fn form :warning))
([hook-fn form level]
(findings hook-fn form level 'metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"))
([hook-fn form level ns filename]
(let [config (if level
{:linters {:metabase/agent-message-lines {:level level}
:metabase/agent-message-exit {:level level}}}
{})]
(binding [clj-kondo.impl.utils/*ctx* {:config config
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(let [input {:node (hooks/parse-string (if (string? form) form (pr-str form)))
:ns ns
:filename filename
:config config}
output (hook-fn input)]
(is (identical? (:node input) (:node output))
"the hook must return the node unchanged so Kondo's normal analysis still runs")
(mapv :message @(:findings clj-kondo.impl.utils/*ctx*)))))))
(def ^:private hook-cases
"`[hook form]` pairs that each report one finding when their linter is enabled."
[[agent-message/lint-msg '(msg lines x)]
[agent-message/lint-teaching-exit '(throw-teaching-error "x")]
[agent-message/lint-jsonrpc-error '(jsonrpc-error id -32602 "x")]
[agent-message/lint-success-content '(success-content "x")]
[agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})"]
[agent-message/lint-op-error '(op-error! idx "add_link" "x")]
[agent-message/lint-ellipsize '(ellipsize "x" 300)]
[agent-message/lint-list-content '(list-content data total {:empty-hint "x"})]])
(defn- linter-enabled-in-ns?
"Whether `config` enables `linter` in namespace `ns-name` through an ns-group's `:config-in-ns` entry."
[config linter ns-name]
(boolean
(some (fn [{:keys [pattern name]}]
(and (string? pattern)
(re-find (re-pattern pattern) ns-name)
(not= :off (get-in config [:config-in-ns name :linters linter :level] :off))))
(:ns-groups config))))
(deftest ^:parallel config-scope-test
(let [config (edn/read-string (slurp ".clj-kondo/config.edn"))]
(testing "GHY-4544: `msg` lines are checked in every OSS and enterprise MCP source namespace, and no test one"
(doseq [[ns-name enabled?] {"metabase.mcp.ui-resource" true
"metabase.mcp.transport" true
"metabase.mcp.v2.tools.browse" true
"metabase-enterprise.mcp.usage" true
"metabase-enterprise.mcp.v2.tools.browse" true
"metabase.mcp.ui-resource-test" false
"metabase.mcp.v2.tools.browse-test" false
"metabase.mcp.test-util" false
"metabase-enterprise.mcp.usage-test" false
"metabase-enterprise.mcp.v2.tools.browse-test" false
"metabase.queries.models.card" false}]
(testing ns-name
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-lines ns-name))))))
(testing "GHY-4544: exits are checked in MCP transport and v2 source namespaces, whose 4xx errors reach the agent"
(doseq [[ns-name enabled?] {"metabase.mcp.transport" true
"metabase.mcp.v2.common" true
"metabase.mcp.v2.tools.browse" true
"metabase-enterprise.mcp.v2.common" true
"metabase.mcp.settings" false
"metabase.mcp.validation" false
"metabase-enterprise.mcp.usage" false
"metabase.mcp.transport-test" false
"metabase.mcp.v2.tools.browse-test" false
"metabase-enterprise.mcp.v2.tools.browse-test" false
"metabase.queries.models.card" false}]
(testing ns-name
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-exit ns-name))))))
(testing "GHY-4544: `:empty-hint` is checked on `list-content` and on wrappers passing their options map to it"
(doseq [fn-sym '[metabase.mcp.v2.common/list-content
metabase.mcp.v2.tools.browse/paged-list-content]]
(testing fn-sym
(is (= 'hooks.metabase.agent-message/lint-list-content
(get-in config [:hooks :analyze-call fn-sym]))))))
(testing (str "GHY-4544: `check-execute-sql-enabled!` isn't checked, since it interpolates its subject into "
"its own `msg` and a plain string is the intended way to name the tool")
(is (nil? (get-in config [:hooks
:analyze-call
(symbol "metabase.mcp.v2.queries" "check-execute-sql-enabled!")]))))))
(deftest ^:parallel scope-test
(testing "GHY-4544: the hooks leave scoping to config, reporting in any namespace where their linter is enabled"
(doseq [[hook form] hook-cases
[ns filename] [['metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"]
['metabase.queries.models.card "src/metabase/queries/models/card.clj"]
['metabase.mcp.v2.tools.browse-test "test/metabase/mcp/v2/tools/browse_test.clj"]]]
(testing (str ns " " (pr-str form))
(is (= 1 (count (findings hook form :warning ns filename)))))))
(testing "nothing is reported when the linter is off or unconfigured"
(doseq [[hook form] hook-cases
level [:off nil]]
(testing (str level " " (pr-str form))
(is (empty? (findings hook form level)))))))
(deftest ^:parallel msg-test
(testing "GHY-4544: a vector of single-line string literals with matching arguments is accepted"
(is (empty? (findings agent-message/lint-msg
'(msg ["Table %s: %d of %d fields." "Continue with `offset: %d`."] t n total next)))))
(testing "specifiers that consume no argument aren't counted, and an explicit index can repeat an argument"
(is (empty? (findings agent-message/lint-msg '(msg ["100%% done."]))))
(is (empty? (findings agent-message/lint-msg '(msg ["%1$s, then %1$s again, then %<s"] x)))))
(testing "the lines must be a literal vector"
(is (=? [#".*vector.*"]
(findings agent-message/lint-msg '(msg lines x))))
(is (=? [#".*vector.*"]
(findings agent-message/lint-msg '(msg "one line" x)))))
(testing "a line may be split across a `str` of string literals"
(is (empty? (findings agent-message/lint-msg '(msg [(str "Table %s has " "%d fields.")] t n))))
(is (=? [#".*1 argument.*2.*"]
(findings agent-message/lint-msg '(msg [(str "only " "%s")] a b)))))
(testing "each line must be a string literal or a `str` of string literals"
(is (=? [#".*string literal or a `str` of string literals.*"]
(findings agent-message/lint-msg '(msg ["ok" line]))))
(is (=? [#".*string literal or a `str` of string literals.*"]
(findings agent-message/lint-msg '(msg [(str "split " x)])))))
(testing "a line can't contain a line break, control, or private-use character; each line is its own string"
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg '(msg ["first\nsecond"]))))
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg '(msg [(str "first\n" "second")]))))
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0x2028) "second")]))))
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg '(msg ["tab\tseparated"]))))
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg '(msg ["done.%n"]))))
(testing "GHY-4544: a private-use character, which the truncation markers use, is flagged too"
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0xE000) "second")]))))
(is (=? [#".*own string.*"]
(findings agent-message/lint-msg (list 'msg [(str "marked" (char 0xF8FF))]))))))
(testing "the argument count must match the specifiers across all lines"
(is (=? [#".*2 arguments.*1.*"]
(findings agent-message/lint-msg '(msg ["%s and" "%s"] a))))
(is (=? [#".*1 argument.*2.*"]
(findings agent-message/lint-msg '(msg ["only %s"] a b)))))
(testing (str "GHY-4544: a reference `String/format` can't resolve is flagged, since the runtime would fall back "
"to rendering every part quoted")
(testing "`%<` with no specifier before it to repeat, which counts as no argument at all"
(is (=? [#".*can't exist.*"]
(findings agent-message/lint-msg '(msg ["Value: %<s"]))))
(is (=? [#".*can't exist.*"]
(findings agent-message/lint-msg '(msg ["100%% done," "then %<d"])))))
(testing "an explicit index of zero, which is below the first argument"
(is (=? [#".*can't exist.*"]
(findings agent-message/lint-msg '(msg ["Value: %0$s"]))))
(is (=? [#".*can't exist.*"]
(findings agent-message/lint-msg '(msg ["Value: %0$s"] x)))))
(testing "a `%<` that repeats the first argument is still accepted"
(is (empty? (findings agent-message/lint-msg '(msg ["%s then %<s"] x)))))))
(deftest ^:parallel msg-specifier-test
(testing "GHY-4544: only `%s` with no flags but `<`, `%d` with flags and width, and `%%` are allowed"
(doseq [line ["%s" "%d" "%1$s" "%,d" "%05d" "%-8d" "%(d" "%1$,10d"
"%s again %<s" "%d again %<d" "%s (100%%)"]]
(testing line
(is (empty? (findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
(testing "GHY-4544: any other conversion, or a `%s` or `%d` that reshapes its value, is flagged"
(doseq [line ["%b" "%h" "%c" "%x" "%X" "%f" "%e" "%o" "%g" "%a" "%tY" "%TY" "%B"
"%S" "%.20s" "%10s" "%-5s" "%#s" "%1$.3s" "%-10S" "%.1f" "%.2d" "%1$.2d"]]
(testing line
(is (=? [#"A `msg` line may only use `%s`, `%d`, or `%%`; .* would alter or hide the value\."]
(findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
(testing "GHY-4544: a `%` that doesn't begin a valid format specifier is flagged, since `String/format` rejects it"
(doseq [line ["Braces %}" "Dot %." "Trailing %" "Space % " "Unknown %q" "Bad date %tq" "%" "%1$"
;; GHY-4544: an argument index too large to be one is malformed, not an arithmetic overflow
"%999999999999999999999999$s"]]
(testing (pr-str line)
(is (=? [#"A `%` in a `msg` line must begin a format specifier.*`%%`.*"]
(findings agent-message/lint-msg (list 'msg [line]))))))
(testing "an index at the digit cap still parses, and is reported as an argument count"
(is (=? [#".*999999999 arguments.*"]
(findings agent-message/lint-msg '(msg ["%999999999$s"] x))))))
(testing "valid specifiers, including an escaped percent sign, aren't flagged as malformed"
(doseq [form ['(msg ["100%% done"])
'(msg ["%,d rows"] n)
'(msg ["%1$s, then %<s"] x)
'(msg [(str "50%" "% of %s")] x)]]
(testing (pr-str form)
(is (empty? (findings agent-message/lint-msg form)))))))
(def ^:private stringy-texts
"Message arguments built as text rather than with `msg`."
['"Query failed."
'(str "Query failed: " err)
'(format "Table %s not found" (pr-str t))
'(cond-> (json/encode body) message (str "\n" message))
'(if x "a" (str "b" y))
'(if x (msg ["a"]) (str "b" y))
'(when-let [e (:error r)] (str/join ", " e))
'(or (:message r) (tru "Unknown error"))
'(let [s (pr-str x)] (str "Bad value " s))
'(cond (:a x) (msg ["a"]) :else "b")
'(condp = x :a (str "a" y) (msg ["b"]))
'(condp = x :a (msg ["a"]) "b")
'(case x :a (msg ["a"]) "b")
'(case x :a "a" :b (msg ["b"]))
'(->> bad (map name) (str/join ", "))
'(-> x (str " suffix"))
'(-> x name str)
'(some-> e ex-message)
'(some->> xs (map name) (clojure.string/join ", "))
'(cond->> xs x (map name) y (str/join ", "))
'(cond-> "x" y (subs 1))
'(as-> x v (assoc v :a 1) (json/encode v))
'(doto (str "a" b) println)
'(let [s x] (-> s (format y)))])
(def ^:private message-texts
"Message arguments that aren't syntactically text."
['(msg ["Table %s not found."] t)
'message
'(if x (msg ["a"]) (msg ["b"]))
'(render-error e)
'(cond (str x) (msg ["a"]) :else (msg ["b"]))
'(condp = x "a" (msg ["a"]) (msg ["b"]))
'(condp get x "a" :>> render-error (msg ["b"]))
'(case x "a" (msg ["a"]) ("b" "c") (msg ["b"]))
'(-> "x" count)
'(->> "x" (map str) set)
'(some-> (str "x") render-error)
'(-> x)
'(-> e :message)
'(cond-> {:a 1} x (assoc :b "c"))
'(as-> "x" v (count v))
'(doto x (println "a"))])
(deftest ^:parallel teaching-exit-test
(doseq [fn-name ["throw-teaching-error" "error-content"]]
(testing (str "GHY-4544: text passed to `" fn-name "` is flagged")
(doseq [text stringy-texts]
(testing (pr-str text)
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
(findings agent-message/lint-teaching-exit (list (symbol "common" fn-name) text)))))))
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls")
(doseq [text message-texts]
(testing (pr-str text)
(is (empty? (findings agent-message/lint-teaching-exit
(list (symbol fn-name) text {:status-code 404}))))))))
(testing "only the message argument is checked"
(is (empty? (findings agent-message/lint-teaching-exit '(error-content (msg ["x"]) (str "code"))))))
(testing "nothing is reported when the linter is off"
(is (empty? (findings agent-message/lint-teaching-exit '(throw-teaching-error "x") :off)))))
(def ^:private helper-cases
"`[hook fn-name form-fn]`: a message-taking helper's hook, the name its finding cites, and a function of a message
returning a call that passes it to the helper."
[[agent-message/lint-op-error "op-error!" #(list 'dashboard-ops/op-error! 'idx "add_link" %)]
[agent-message/lint-ellipsize "ellipsize" #(list 'common/ellipsize % 300)]
[agent-message/lint-list-content "list-content" #(list 'common/list-content 'rows 0 {:offset 0 :empty-hint %})]
[agent-message/lint-list-content "paged-list-content"
#(list 'paged-list-content 'args 'rows {:empty-hint %} 'identity)]])
(deftest ^:parallel message-helper-test
(doseq [[hook fn-name form-fn] helper-cases]
(testing (str "GHY-4544: text passed as `" fn-name "`'s message is flagged")
(doseq [text stringy-texts]
(testing (pr-str text)
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
(findings hook (form-fn text)))))))
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls as its message")
(doseq [text message-texts]
(testing (pr-str text)
(is (empty? (findings hook (form-fn text))))))))
(testing "only the message argument is checked"
(is (empty? (findings agent-message/lint-op-error '(op-error! (str "0") (str "op") (msg ["x"])))))
(is (empty? (findings agent-message/lint-ellipsize '(ellipsize (msg ["x"]) (str "300")))))
(is (empty? (findings agent-message/lint-list-content '(list-content (str "data") 0 {:param (str "x")}))))
(is (empty? (findings agent-message/lint-list-content '(list-content data 0 opts))))
(is (empty? (findings agent-message/lint-list-content '(list-content data 0))))))
(deftest ^:parallel jsonrpc-error-test
(testing "GHY-4544: text passed as the JSON-RPC error message is flagged"
(doseq [text stringy-texts]
(testing (pr-str text)
(is (=? [#".*passes text to `jsonrpc-error`.*`msg`.*"]
(findings agent-message/lint-jsonrpc-error (list 'transport/jsonrpc-error 'id -32602 text)))))))
(testing "a `msg`, symbol, or other call as the message is accepted"
(doseq [text message-texts]
(testing (pr-str text)
(is (empty? (findings agent-message/lint-jsonrpc-error (list 'jsonrpc-error 'id -32602 text)))))))
(testing "only the third argument is the message"
(is (empty? (findings agent-message/lint-jsonrpc-error '(jsonrpc-error "id" (str "code") (msg ["x"])))))))
(deftest ^:parallel success-content-test
(testing "GHY-4544: text passed to `success-content` is flagged"
(doseq [text stringy-texts]
(testing (pr-str text)
(is (=? [#"`success-content` takes data to JSON-encode or a `msg`.*"]
(findings agent-message/lint-success-content (list 'common/success-content text)))))))
(testing "a payload, a `msg`, or a symbol is accepted"
(doseq [text (conj message-texts '{:data rows} '[a b])]
(testing (pr-str text)
(is (empty? (findings agent-message/lint-success-content (list 'success-content text))))))
(is (empty? (findings agent-message/lint-success-content
'(success-content {:data rows} (str "structured")))))))
(deftest ^:parallel ex-info-test
(testing "GHY-4544: an ex-info carrying a 4xx status code or an error code is flagged"
(doseq [form ["(ex-info \"Card not found\" {:status-code 404})"
"(ex-info (str \"Bad \" x) {:status-code 400 :field f})"
"(ex-info \"Nope\" {::common/error-code c})"
"(ex-info \"Nope\" {:error-code c})"
"(ex-info (msg [\"Nope\"]) {:error-code c})"]]
(testing form
(is (=? [#".*`throw-teaching-error` and a `msg`.*"]
(findings agent-message/lint-ex-info form))))))
(testing "other ex-info calls aren't flagged"
(doseq [form ["(ex-info \"boom\" {:status-code 500})"
"(ex-info \"boom\" {:status-code code})"
"(ex-info \"boom\" {:foo 1})"
"(ex-info \"boom\" data)"
"(ex-info \"boom\" (merge {:status-code 400} data))"
"(ex-info \"boom\")"]]
(testing form
(is (empty? (findings agent-message/lint-ex-info form))))))
(testing "nothing is reported when the linter is off"
(is (empty? (findings agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})" :off)))))

View File

@@ -0,0 +1,138 @@
(ns hooks.metabase.toucan.db-ns-test
(:require
[clj-kondo.hooks-api :as hooks]
[clj-kondo.impl.utils]
[clojure.test :refer :all]
[hooks.metabase.toucan.db-ns :as toucan.db-ns]))
(defn- confinement-findings
"Only the `t2-query-namespace` findings, so these tests are not affected by the query-hygiene
linter that shares this hook."
[findings]
(filter #(= :metabase/t2-query-namespace (:type %)) findings))
(defn- lint-query-call [form ns-sym & [filename modules]]
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}
:metabase/unsafe-app-db-query {:level :warning}}}
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(let [input {:node (hooks/parse-string (pr-str form))
:ns ns-sym
:filename (or filename "src/metabase/foo/bar.clj")
:config {:metabase/modules modules}}
output (toucan.db-ns/lint-query-call input)]
(is (identical? (:node input) (:node output))
"the hook must return the node unchanged so Kondo's normal analysis still runs")
@(:findings clj-kondo.impl.utils/*ctx*))))
(deftest ^:parallel t2-query-must-live-in-db-namespace-test
(testing "a query call outside a db namespace is flagged"
(is (=? [{:type :metabase/t2-query-namespace
:message #".*`t2/select-one`.*"}]
(lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.models.card))))
(testing "metabase.<module>.db is allowed"
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.db)))))
(testing "metabase-enterprise.<module>.db is allowed"
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase-enterprise.sandbox.db)))))
(testing "metabase.driver.<driver>.db is allowed for driver modules"
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Database :id 1) 'metabase.driver.bigquery-cloud-sdk.db)))))
(testing "a nested db namespace is not a module db namespace"
(is (=? [{:type :metabase/t2-query-namespace}]
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db))))
(testing "a namespace merely ending in db is not a module db namespace"
(is (=? [{:type :metabase/t2-query-namespace}]
(lint-query-call '(t2/delete! :model/Card :id 1) 'metabase.queries.dashboards-db))))
(testing "files in a test source tree are exempt even when the namespace name doesn't look like a test"
(is (empty? (lint-query-call '(t2/update! :model/User 1 {:sso_source nil}) 'metabase.sso.test-helpers
"test/metabase/sso/test_helpers.clj")))
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.remote-sync.test-helpers
"/repo/enterprise/backend/test/metabase_enterprise/remote_sync/test_helpers.clj"))))
(testing "app-db wrappers around Toucan 2 are reported the same way"
(is (=? [{:type :metabase/t2-query-namespace
:message #".*`mdb/update-or-insert!`.*"}]
(lint-query-call '(mdb/update-or-insert! :model/Foo {:id 1}) 'metabase.foo.models.foo))))
(testing "fully-qualified calls are reported by their written name"
(is (=? [{:type :metabase/t2-query-namespace
:message #".*`toucan2.core/insert!`.*"}]
(lint-query-call '(toucan2.core/insert! :model/Card {}) 'metabase.queries.models.card)))))
(deftest ^:parallel t2-query-namespace-follows-the-module-tree-test
(let [modules '{queries {}
metabot {}
metabot.llm {}
lib.be {:ns-prefix "metabase.lib-be"}
enterprise/sandbox {}}]
(testing "a nested module owns its own db namespace"
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.metabot.llm.db nil modules))))
(testing "the parent's db namespace stays its own"
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.metabot.db nil modules))))
(testing "a module with an explicit :ns-prefix is resolved through it, not its dotted name"
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.lib-be.db nil modules)))
(is (=? [{:type :metabase/t2-query-namespace}]
(lint-query-call '(t2/select :model/Card) 'metabase.lib.be.db nil modules))))
(testing "a db namespace under a directory naming no module is still a finding"
(is (=? [{:type :metabase/t2-query-namespace}]
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db nil modules)))
(is (=? [{:type :metabase/t2-query-namespace}]
(lint-query-call '(t2/query {:select [:*]}) 'metabase.metabot.llm.models.db nil modules))))
(testing "enterprise modules resolve through the metabase-enterprise root"
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.sandbox.db nil modules))))))
(deftest ^:parallel unmarked-value-in-a-db-namespace-test
(testing "a symbol reaching a value slot is flagged"
(is (=? [{:type :metabase/unsafe-app-db-query
:message #"`locale` reaches a SQL value slot unmarked.*"}]
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]}) 'metabase.foo.db))))
(testing "a marked value is not flagged"
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
'metabase.foo.db))))
(testing "a literal cannot carry a request value and is not flagged"
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale "de"]}) 'metabase.foo.db))))
(testing "a column reference is not a value"
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :a.id :b.id]}) 'metabase.foo.db))))
(testing "values nested under a boolean connective are reached"
(is (=? [{:message #"`b`.*"}]
(lint-query-call '(t2/select :model/X {:where [:and [:= :x [:auto/param a]] [:= :y b]]})
'metabase.foo.db))))
(testing "only db namespaces are linted for unmarked values"
(is (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
'metabase.foo.models.thing)))))
(testing "a test source tree is exempt"
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
'metabase.foo.db "test/metabase/foo/db_test.clj")))))
(deftest ^:parallel kv-arg-style-test
(testing "a query written as :column value pairs is flagged whatever the values are"
(are [form] (=? [{:type :metabase/unsafe-app-db-query, :message #"Pass this query a map.*"}]
(lint-query-call form 'metabase.foo.db))
'(t2/select :model/X :locale locale)
'(t2/select :model/X :archived false)
'(t2/select :model/X :locale [:auto/param locale])
'(t2/select-one :model/X :id (long id))))
(testing "one finding per call, not one per pair"
(is (= 1 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
'metabase.foo.db)))))
(testing "a fn with an argument before the model is still recognised"
(is (=? [{:message #"Pass this query a map.*"}]
(lint-query-call '(t2/select-one-fn :value :model/Setting :key k) 'metabase.foo.db))))
(testing "a map query is not flagged"
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
'(t2/update! :model/X {:locale [:auto/param locale]} {:msgstr "x"}))))
(deftest ^:parallel write-calls-are-not-linted-for-values-test
(testing "an insert's values are written, not filtered on, so they are not flagged"
(are [form] (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
(lint-query-call form 'metabase.foo.db)))
'(t2/insert! :model/X :key k :value v)
'(t2/insert-returning-instances! :model/X :key k :value v)))
(testing "a select's values are still flagged"
(is (=? [{:type :metabase/unsafe-app-db-query}]
(lint-query-call '(t2/select :model/X :key k) 'metabase.foo.db)))))
(deftest ^:parallel operator-arity-test
(testing "a value operator with an unexpected arity still has its args examined"
(is (seq (lint-query-call '(t2/select :model/X {:where [:= :a b c]}) 'metabase.foo.db)))
(is (seq (lint-query-call '(t2/select :model/X {:where [:between :a lo hi]}) 'metabase.foo.db)))))

View File

@@ -12,6 +12,7 @@
"enterprise/backend"
"bin/build"
"bin/lint-migrations-file"
"bin/load-namespaces"
"bin/release-list"
#_"mage/src"
#_"mage/test"

View File

@@ -16,7 +16,7 @@ skip =
frontend/src/metabase/querying/expressions/tokenizer/lezer.js,
frontend/src/metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor/language.ts,
frontend/src/metabase/static-viz/constants/char-sizes.ts,
frontend/src/metabase/visualizations/lib/mapping_codes.ts,
frontend/src/metabase/viz-core/lib/mapping_codes.ts,
frontend/*/mocks/*,
modules/drivers/mongo/src/metabase/driver/mongo/operators.clj,
src/metabase/analyze/classifiers/name.clj,

View File

@@ -2,7 +2,7 @@
// imported, vs the *allowed* edges in module-boundaries.mjs). Consumed by
// .github/scripts/create-test-plan.ts.
//
// bunx depcruise frontend/src enterprise/frontend/src \
// bunx depcruise frontend/src enterprise/frontend/src .storybook/preview.tsx \
// --config .dependency-cruiser.cjs --output-type json > dependency-graph.json
/** @type {import('dependency-cruiser').IConfiguration} */
@@ -10,10 +10,13 @@ module.exports = {
options: {
// Keep only edges between our own source files (drops node_modules etc.),
// which keeps the JSON small.
includeOnly: "^(frontend/src|enterprise/frontend/src)/",
includeOnly: "^(frontend/src|enterprise/frontend/src|\\.storybook)/",
doNotFollow: { path: "node_modules" },
// No rules here, so the cycle and orphan analysis has no consumer and only costs time.
skipAnalysisNotInRules: true,
// Resolve the `"*": ["./frontend/src/*", ...]` aliases so bare imports like
// `metabase/lib/foo` map back to a real file.
tsConfig: { fileName: "tsconfig.json" },

18
.github/CODEOWNERS vendored
View File

@@ -10,6 +10,7 @@ src/metabase/query_processor @metabase/core-backend-querying-platform
src/metabase/graph @metabase/graphy
enterprise/backend/src/metabase_enterprise/dependencies @metabase/graphy
enterprise/frontend/src/metabase-enterprise/dependencies @metabase/graphy
enterprise/frontend/src/metabase-enterprise/monitor/dependency-diagnostics @metabase/graphy
# @metabase/embedding
@@ -41,6 +42,9 @@ docs/api.json # auto-generated
docs/configuring-metabase/environment-variables.md # auto-generated
docs/configuring-metabase/config-template.md # auto-generated
docs/installation-and-operation/commands.md # auto-generated
docs/ai/providers.md # auto-generated
docs/embedding/sdk/api # auto-generated
docs/embedding/eajs/snippets # auto-generated
# @metabase/devexp
.github @metabase/devexp
@@ -49,5 +53,17 @@ docs/installation-and-operation/commands.md # auto-generated
src/metabase/analytics/prometheus.clj @metabase/cloud-ops
# @metabase/metabot-frontend
enterprise/frontend/src/metabase-enterprise/metabot @metabase/metabot-frontend
enterprise/frontend/src/metabase-enterprise/metabot @metabase/metabot-frontend
frontend/src/metabase/metabot @metabase/metabot-frontend
# @metabase/core-frontend-gadget
bin/custom-viz @metabase/core-frontend-gadget
docs/developers-guide/custom-visualizations.md @metabase/core-frontend-gadget
enterprise/frontend/src/custom-viz @metabase/core-frontend-gadget
enterprise/frontend/src/metabase-enterprise/api/custom-viz-plugin.ts @metabase/core-frontend-gadget
enterprise/frontend/src/metabase-enterprise/custom_viz @metabase/core-frontend-gadget
frontend/src/metabase-types/api/custom-viz-plugin.ts @metabase/core-frontend-gadget
frontend/src/metabase/plugins/oss/custom-viz.ts @metabase/core-frontend-gadget
frontend/src/metabase/visualizations/custom-visualizations @metabase/core-frontend-gadget
frontend/test/__support__/custom-viz-fixtures @metabase/core-frontend-gadget
.github/workflows/release-custom-viz.yml @metabase/devexp @metabase/core-frontend-gadget

19
.github/actionlint.yaml vendored Normal file
View File

@@ -0,0 +1,19 @@
self-hosted-runner:
# Custom runner labels that actionlint cannot know about. Everything else is
# selected through `vars.DEFAULT_RUNNER_KEY` and is not checked.
labels:
- arm-ubuntu-24-2core
paths:
# GitHub added parallel steps (`parallel`, `background`, `wait`, `wait-all`,
# `cancel`) in June 2026, and actionlint 1.7.12 does not know the schema yet:
# https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
.github/workflows/backend.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'
.github/workflows/lint.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'
.github/workflows/pr.checks.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'

View File

@@ -0,0 +1,136 @@
name: Bundle Size Check
description: |
Measures app + embedding SDK bundle sizes from the current and base-ref
uberjars (reusing the builds that already ran) plus their bundle-stats
artifacts, and flags gzipped size changes beyond the threshold for the main
app's initial bundle and the chunked SDK's reachable total. The base ref is
read back off the current uberjar so that both sides differ only by the PR.
Skips when the base-ref artifacts aren't available rather than rebuilding from
source (which would compare across pipelines).
inputs:
edition:
description: 'Metabase edition (ee or oss)'
required: true
threshold-percent:
description: 'Percent threshold for bundle size change'
required: true
outputs:
app_status:
description: "App initial bundle status: increased, decreased, or stable"
value: ${{ steps.compare.outputs.app_initial_status }}
app_size_change_percent:
description: "App initial bundle size change percent (positive = increase, negative = decrease)"
value: ${{ steps.compare.outputs.app_initial_size_change_percent }}
sdk_status:
description: "Chunked SDK bundle status: increased, decreased, or stable"
value: ${{ steps.compare.outputs.embedding_sdk_chunked_total_status }}
sdk_size_change_percent:
description: "Chunked SDK bundle size change percent (positive = increase, negative = decrease)"
value: ${{ steps.compare.outputs.embedding_sdk_chunked_total_size_change_percent }}
runs:
using: "composite"
steps:
- name: Download current uberjar
uses: ./.github/actions/fetch-artifact
with:
commit: ${{ github.event.pull_request.head.sha || github.sha }}
edition: ${{ inputs.edition }}
extract-path: temp/current-uberjar
- name: Resolve the base ref the current uberjar was built against
id: base-ref
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -uo pipefail
# The uberjar job checks out refs/pull/<n>/merge (the PR head already
# merged into the base branch tip) but names its artifact after head.sha,
# so the comparable base ref is that merge's first parent. Reading it back
# off the jar describes the artifact actually being measured. Using
# pull_request.base.sha instead compares against a stale snapshot of the
# base branch, reporting everything that landed in between as this PR's
# own bundle delta.
unzip -o -j temp/current-uberjar/metabase.jar version.properties -d temp/current-version >/dev/null
built=$(sed -n 's/^hash=//p' temp/current-version/version.properties)
base=$(gh api "/repos/${GITHUB_REPOSITORY}/commits/${built}" \
--jq 'if (.parents | length) == 2 then .parents[0].sha else empty end' 2>/dev/null) || base=""
echo "sha=${base}" >> "$GITHUB_OUTPUT"
echo "The current uberjar was built from ${built}; its base ref is ${base:-unknown}"
- name: Download base-ref uberjar
id: base-jar
if: steps.base-ref.outputs.sha != ''
continue-on-error: true
uses: ./.github/actions/fetch-artifact
with:
commit: ${{ steps.base-ref.outputs.sha }}
edition: ${{ inputs.edition }}
extract-path: temp/base-uberjar
- name: Measure and compare bundle sizes
id: compare
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EDITION: ${{ inputs.edition }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
BASE_SHA: ${{ steps.base-ref.outputs.sha }}
THRESHOLD: ${{ inputs.threshold-percent }}
MEASURE_BUNDLES: app,embedding-sdk-legacy,embedding-sdk-chunked
# The gated bundles, as "<bundle>/<kind>". Each one's step outputs are
# named after its key (see outputPrefix in compare.js), which is what the
# action outputs above read.
GATES: app/initial,embedding-sdk-chunked/total
run: |
set -uo pipefail
skip() {
echo "::notice::Bundle-size check skipped: $1"
for gate in ${GATES//,/ }; do
prefix=$(printf '%s' "$gate" | tr -c 'a-zA-Z0-9' '_')
echo "${prefix}_status=stable" >> "$GITHUB_OUTPUT"
echo "${prefix}_size_change_percent=0" >> "$GITHUB_OUTPUT"
done
exit 0
}
# The base ref needs an already-built uberjar. Rebuilding it from source
# would compare across two build pipelines (the bug this check used to
# have), so skip instead when it — or its stats — isn't available.
[ -n "${BASE_SHA}" ] || skip "no comparable base ref for the current uberjar"
[ -f temp/base-uberjar/metabase.jar ] || skip "base-ref uberjar not found for ${BASE_SHA}"
extract_js() { # $1=jar $2=dest-root
mkdir -p "$2/resources"
unzip -o "$1" \
"frontend_client/app/dist/*" \
"frontend_client/app/embed.js" \
"frontend_client/app/embedding-sdk/*" \
-d "$2/resources" >/dev/null || true
}
fetch_stats() { # $1=sha $2=dest-dir
local name="metabase-bundle-stats-${EDITION}-$1"
local id
id=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${name}" --jq '.artifacts[0].id // empty' 2>/dev/null)
[ -n "${id}" ] || return 1
mkdir -p "$2"
gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}/zip" > "$2.zip" 2>/dev/null || return 1
unzip -o "$2.zip" -d "$2" >/dev/null || return 1
}
extract_js temp/current-uberjar/metabase.jar temp/current
extract_js temp/base-uberjar/metabase.jar temp/base
fetch_stats "${HEAD_SHA}" temp/current/artifacts || skip "bundle stats not found for current ${HEAD_SHA}"
fetch_stats "${BASE_SHA}" temp/base/artifacts || skip "bundle stats not found for base ${BASE_SHA}"
MEASURE_ROOT=temp/current node ./.github/scripts/measure-bundle-sizes.js > temp/current-sizes.json \
|| { echo "::error::Failed to measure current bundle sizes"; exit 1; }
MEASURE_ROOT=temp/base node ./.github/scripts/measure-bundle-sizes.js > temp/base-sizes.json \
|| { echo "::error::Failed to measure base bundle sizes"; exit 1; }
node ./.github/actions/bundle-size-check/compare.js temp/current-sizes.json temp/base-sizes.json

View File

@@ -0,0 +1,133 @@
/* eslint-disable import/no-commonjs */
// Diffs the bundle sizes produced by measure-bundle-sizes.js for the current
// build and the base ref. Every (bundle, kind) is compared strictly against the
// same (bundle, kind) — never one bundle type against another — using gzipped
// (served) bytes.
//
// The report covers every measured bundle; the gates are the subset that drives
// PR comments, named as "<bundle>/<kind>" (e.g. "app/initial",
// "embedding-sdk-chunked/total").
//
// compareBundles is pure (data in, data out) so it can be unit-tested; main()
// is the thin I/O wrapper that reads the size files, prints the report and sets
// the step outputs / exit code.
const fs = require("fs");
const pick = (rows, bundle, kind) => rows.find(row => row.bundle === bundle && row.kind === kind);
const keyOf = row => `${row.bundle}/${row.kind}`;
const mb = bytes => (bytes / 1024 / 1024).toFixed(2);
const percentOf = (currentBytes, baseBytes) =>
baseBytes ? Math.trunc(((currentBytes - baseBytes) * 100) / baseBytes) : 0;
// Output names are derived from the gate key, so the workflow reads
// `app_initial_status`, `embedding_sdk_chunked_total_status`, and so on.
const outputPrefix = gateKey => gateKey.replace(/[^a-zA-Z0-9]+/g, "_");
/**
* Evaluate one gate. Returns the key plus exactly one of:
* - `status` / `percent`: the bundle moved by `percent`.
* - `skip` reason: the gate is non-actionable (reported stable, no comment).
* - `error` reason: the gate inputs are missing (hard failure).
*/
function evaluateGate({ current, base, threshold, gateKey }) {
const [bundle, kind] = gateKey.split("/");
const gateCurrent = pick(current, bundle, kind);
const gateBase = pick(base, bundle, kind);
if (!gateCurrent || !gateBase || !gateBase.gzipBytes) {
return { gateKey, error: `Could not find ${bundle} ${kind} (gzip) in both builds` };
}
// Both sides must measure the bundle the same way. A base ref built before the
// reachableAssets enrichment collapses its total to the initial set, so
// comparing it against the current reachable total reports a phantom jump
// (~30%). Skip until the base ref also carries reachable stats.
if (Boolean(gateCurrent.reachable) !== Boolean(gateBase.reachable)) {
return {
gateKey,
skip:
`"${gateKey}" measured differently on each side ` +
`(current reachable=${Boolean(gateCurrent.reachable)}, base reachable=${Boolean(gateBase.reachable)}); ` +
`the base ref predates the reachable-chunk stats. Resolves once this change is on the base ref.`,
};
}
const percent = percentOf(gateCurrent.gzipBytes, gateBase.gzipBytes);
const status = percent > threshold ? "increased" : percent < -threshold ? "decreased" : "stable";
return { gateKey, status, percent };
}
/**
* Compare two measurement sets and evaluate every gate. Returns a
* human-readable `report` over all measured bundles plus one entry per gate.
*/
function compareBundles({ current, base, threshold, gateKeys }) {
const report = ["=== Bundle sizes (gzipped, current vs base) ==="];
const keys = [...new Set([...current, ...base].map(keyOf))].sort();
for (const key of keys) {
const [bundle, kind] = key.split("/");
const currentRow = pick(current, bundle, kind);
const baseRow = pick(base, bundle, kind);
if (!currentRow || !baseRow) {
report.push(`${key}: present only in ${currentRow ? "current" : "base"} build`);
continue;
}
const diff = currentRow.gzipBytes - baseRow.gzipBytes;
const percent = percentOf(currentRow.gzipBytes, baseRow.gzipBytes);
report.push(`${key}: ${mb(currentRow.gzipBytes)}MB vs ${mb(baseRow.gzipBytes)}MB (${percent}%, ${diff} bytes)`);
}
return { report, gates: gateKeys.map(gateKey => evaluateGate({ current, base, threshold, gateKey })) };
}
module.exports = { compareBundles, outputPrefix };
const setOutput = (name, value) => {
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
function main() {
const [, , currentPath, basePath] = process.argv;
const threshold = Number(process.env.THRESHOLD ?? 2);
const gateKeys = (process.env.GATES ?? "embedding-sdk-chunked/total").split(",").map(key => key.trim());
const current = JSON.parse(fs.readFileSync(currentPath, "utf8"));
const base = JSON.parse(fs.readFileSync(basePath, "utf8"));
const { report, gates } = compareBundles({ current, base, threshold, gateKeys });
console.log(report.join("\n"));
console.log("");
for (const gate of gates) {
const prefix = outputPrefix(gate.gateKey);
if (gate.error) {
// One broken gate must not hide the others, so report them all and fail
// the step at the end.
console.error(`::error::${gate.error}`);
continue;
}
if (gate.skip) {
// Treat the gate as non-actionable: report stable so no regression comment
// is posted, mirroring the shell skip() for missing artifacts.
console.log(`::notice::Bundle-size gate skipped: ${gate.skip}`);
setOutput(`${prefix}_status`, "stable");
setOutput(`${prefix}_size_change_percent`, "0");
continue;
}
console.log(`Gate: ${gate.gateKey} ${gate.percent}% (threshold ${threshold}%) → ${gate.status}`);
setOutput(`${prefix}_status`, gate.status);
setOutput(`${prefix}_size_change_percent`, gate.percent);
}
if (gates.some(gate => gate.error)) {
process.exit(1);
}
}
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,84 @@
const { compareBundles, outputPrefix } = require("./compare");
const SDK_GATE = "embedding-sdk-chunked/total";
const APP_GATE = "app/initial";
const total = (gzipBytes, over = {}) => ({
bundle: "embedding-sdk-chunked",
kind: "total",
gzipBytes,
reachable: true,
...over,
});
const appInitial = gzipBytes => ({ bundle: "app", kind: "initial", gzipBytes });
const compare = ({ current, base, threshold = 2, gateKeys = [SDK_GATE] }) =>
compareBundles({ current, base, threshold, gateKeys });
describe("outputPrefix", () => {
it("turns a gate key into a step-output prefix", () => {
expect(outputPrefix(APP_GATE)).toBe("app_initial");
expect(outputPrefix(SDK_GATE)).toBe("embedding_sdk_chunked_total");
});
});
describe("compareBundles", () => {
it("gates on the chunked SDK total and flags an increase past the threshold", () => {
const { gates, report } = compare({ current: [total(110)], base: [total(100)] });
expect(gates).toEqual([{ gateKey: SDK_GATE, status: "increased", percent: 10 }]);
expect(report[0]).toContain("current vs base");
});
it("flags a decrease and stays stable within the threshold", () => {
expect(compare({ current: [total(90)], base: [total(100)] }).gates[0]).toMatchObject({
status: "decreased",
percent: -10,
});
expect(compare({ current: [total(101)], base: [total(100)] }).gates[0]).toMatchObject({
status: "stable",
percent: 1,
});
});
it("evaluates each gate against its own bundle", () => {
const { gates } = compare({
current: [total(100), appInitial(110)],
base: [total(100), appInitial(100)],
gateKeys: [APP_GATE, SDK_GATE],
});
expect(gates).toEqual([
{ gateKey: APP_GATE, status: "increased", percent: 10 },
{ gateKey: SDK_GATE, status: "stable", percent: 0 },
]);
});
it("skips when the two sides measured a gate differently (reachable mismatch)", () => {
const { gates } = compare({
current: [total(130, { reachable: true })],
base: [total(100, { reachable: false })],
});
expect(gates[0].status).toBeUndefined();
expect(gates[0].skip).toContain("predates the reachable-chunk stats");
});
it("errors when a gate bundle is missing on either side, without hiding the others", () => {
const { gates } = compare({
current: [appInitial(100)],
base: [appInitial(100)],
gateKeys: [APP_GATE, SDK_GATE],
});
expect(gates[0]).toEqual({ gateKey: APP_GATE, status: "stable", percent: 0 });
expect(gates[1].error).toContain("embedding-sdk-chunked total");
});
it("reports a bundle/kind present on only one side without erroring", () => {
const { report } = compare({ current: [total(100), appInitial(50)], base: [total(100)] });
expect(report.some(line => line.includes("present only in current build"))).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
name: Collect profile
description: >-
Attach a flamegraph and per-test wall times to a job whose tests ran under clj-async-profiler.
Does nothing when the run was not profiled.
inputs:
profiled:
required: true
description: >-
"true" when the test step ran under the profiler. Passed in rather than read from the
environment so the decision stays recorded by the step that made it.
artifact-name:
required: true
description: Name for the uploaded artifact. Must be unique across the jobs of one run.
runs:
using: "composite"
steps:
# Unlike a JUnit failure report, these run on a green build too — a suite that passes slowly is
# exactly what profiling is for.
#
# `awk 'NR<=25'` rather than `head -25`: the runner ignores SIGPIPE, so `head` exiting early
# leaves `sort` with an EPIPE write error and `pipefail` fails the step. `|| true` covers `grep`
# finding no junit files.
- name: Summarize test wall times
if: ${{ inputs.profiled == 'true' }}
continue-on-error: true
shell: bash
run: |
{
echo "### Slowest test namespaces (seconds)"
echo '```'
grep -h '<testsuite' target/junit/*.xml 2>/dev/null \
| sed -E 's|.*<testsuite name="([^"]*)" time="([^"]*)".*|\2 \1|' \
| sort -rn | awk 'NR<=25' || true
echo '```'
echo "### Slowest tests (seconds)"
echo '```'
grep -h '<testcase' target/junit/*.xml 2>/dev/null \
| sed -E 's|.*classname="([^"]*)" name="([^"]*)" time="([^"]*)".*|\3 \1/\2|' \
| sort -rn | awk 'NR<=25' || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Only the rendered flamegraph is kept: the raw collapsed stacks it was built from run to
# hundreds of MB on a long suite, and are reproducible locally from the same test selection.
#
# The info-level test log rides along on a green profiled run too. The failure-only log artifact
# the test jobs upload is not written when nothing failed, which is exactly the run being profiled.
- name: Upload profile
if: ${{ inputs.profiled == 'true' }}
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact-name }}
path: |
target/clj-async-profiler/results/*flamegraph.html
target/junit/*.xml
logs/test-log.json
retention-days: 14
if-no-files-found: warn

View File

@@ -1,4 +1,5 @@
name: Create Backport PR
description: Cherry-picks a commit onto a release branch and opens the backport pull request.
inputs:
target-version: # major version number like 34 or 86
@@ -24,6 +25,8 @@ runs:
COMMIT: ${{ inputs.backport-commit }}
shell: bash
run: | # bash
source "${{ github.action_path }}/kondo-ratchets.sh"
git config --global user.email "metabase-bot@metabase.com"
git config --global user.name "Metabase bot"
@@ -33,7 +36,7 @@ runs:
git checkout ${TARGET_BRANCH}
git fetch --all
git checkout -b ${BACKPORT_BRANCH}
git cherry-pick ${COMMIT} || true
cherry_pick_backport "${COMMIT}"
CONFLICT_LIST=$(git ls-files -u)
@@ -42,13 +45,10 @@ runs:
git cherry-pick --abort
echo "Could not cherry pick because of a conflict"
echo "has-conflicts=true" >> $GITHUB_OUTPUT
echo "has-conflicts=true" >> "$GITHUB_OUTPUT"
# Add a shell script for resolving conflicts
echo "git reset HEAD~1" > ./backport.sh
echo "rm ./backport.sh" >> ./backport.sh
echo "git cherry-pick ${COMMIT}" >> ./backport.sh
echo "echo 'Resolve conflicts and force push this branch.\n\nTo backport translations run: bin/i18n/merge-translations <release-branch>'" >> ./backport.sh
write_backport_script "${COMMIT}"
chmod +x ./backport.sh
git add ./backport.sh
git commit -m "Add backport resolution script"
@@ -57,44 +57,43 @@ runs:
#${ORIGINAL_PULL_REQUEST_NUMBER}
> [!IMPORTANT]
> Manual conflict resolution is required.
> Checkout the branch and run \`./backport.sh\` script. Force push your changes after cherry-picking. Check the box below when done.
> Checkout the branch and run \`./backport.sh\` script. Force push your changes after cherry-picking. Mark the PR as ready for review once conflicts have been addressed.
Conflicts:
\`\`\`shell
${CONFLICT_LIST}
\`\`\`
- [ ] Conflicts resolved
END
)
else
echo "has-conflicts=false" >> $GITHUB_OUTPUT
echo "has-conflicts=false" >> "$GITHUB_OUTPUT"
PR_BODY="#${ORIGINAL_PULL_REQUEST_NUMBER}"
fi
git push -u origin ${BACKPORT_BRANCH}
BACKPORT_PR_URL=$(gh pr create --base "${TARGET_BRANCH}" --head "${BACKPORT_BRANCH}" --label "was-backported" --assignee "${{ github.event.pull_request.user.login }}" --title "🤖 backported \"${ORIGINAL_TITLE}\"" --body "${PR_BODY}")
PR_ARGS=(--base "${TARGET_BRANCH}" --head "${BACKPORT_BRANCH}" --label "was-backported" --assignee "${{ github.event.pull_request.user.login }}" --title "🤖 v${TARGET_VERSION} backported \"${ORIGINAL_TITLE}\"" --body "${PR_BODY}")
if [ "$CONFLICTS" -gt 0 ]; then
PR_ARGS+=(--draft)
fi
BACKPORT_PR_URL=$(gh pr create "${PR_ARGS[@]}")
BACKPORT_PR_NUMBER=${BACKPORT_PR_URL##*/}
echo "backport_pr_number=$BACKPORT_PR_NUMBER" >> $GITHUB_OUTPUT
echo "backport_pr_number=$BACKPORT_PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "New PR has been created: #${BACKPORT_PR_NUMBER}"
git checkout ${{ github.sha }}
- name: Auto approve backport PR
- name: Approve and Merge the PR
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
uses: juliangruber/approve-pull-request-action@68fcc9a5a73b5641cadf757cf99d73720dcb05d0 # v2.1.0
with:
github-token: ${{ inputs.github-token-2 }}
number: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
- name: Enable Pull Request Automerge
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: gh pr merge --squash --auto "$PR_NUMBER"
run: |
gh pr review --approve "$PR_NUMBER"
gh pr merge --squash --auto "$PR_NUMBER"
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
# uses a different token than the token that created the PR so we can approve and merge it
GH_TOKEN: ${{ inputs.github-token-2 }}
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
- uses: ./.github/actions/notify-pull-request

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env bash
set -euo pipefail
action_dir=$(cd "$(dirname "$0")" && pwd)
cut_release_workflow="$action_dir/../../workflows/cut-release-branch.yml"
# shellcheck source=.github/actions/create-backport/kondo-ratchets.sh
source "$action_dir/kondo-ratchets.sh"
# Any editor launch fails the run, so a continuation that waits on an editor cannot pass.
export GIT_EDITOR=false
test_root=
trap '[ -z "$test_root" ] || rm -rf -- "$test_root"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
test_root=$(mktemp -d)
active_ratchets='{:ignore-counts {:example 1}}'
disabled_ratchets=';; Kondo ignore ratchets apply only to master; this release branch opts out.
;; The test and fixer recognize this explicit opt-out.
{:disabled true}'
# Coverage:
#
# Target state Incoming change Other conflict Expected result
# ----------------- -------------------- --------------- ------------------------------------------
# active ratchets unrelated file none leave ratchets unchanged
# active ratchets ratchets none install the release opt-out
# disabled ratchets ratchets none keep the opt-out; finish with an empty commit
# divergent ratchets ratchets none resolve ratchets and finish the cherry-pick
# divergent ratchets ratchets (on a pty) none finish without opening an editor
# active ratchets unrelated file app.txt leave the conflict for manual resolution
# divergent ratchets ratchets + app.txt app.txt resolve only ratchets
# release cut ratchets none cut and backport commit the same opt-out
# n/a cut-release workflow n/a the workflow calls the shared writer
# any invalid commit n/a propagate the cherry-pick failure
# divergent ratchets generated backport.sh app.txt resolve only ratchets through the manual script
#
# Known gaps: an older target with no ratchets file, and an incoming commit that
# deletes the ratchets file.
test_unrelated_backport_preserves_target_ratchets() (
local repo="$test_root/unrelated"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_eq feature "$(cat app.txt)" "the unrelated change was not cherry-picked"
assert_ratchets "$active_ratchets"
assert_cherry_pick_completed
assert_worktree_clean
)
test_clean_ratchet_backport_installs_release_opt_out() (
local repo="$test_root/clean-ratchet"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_disabled_release_absorbs_ratchet_only_backport() (
local repo="$test_root/disabled-release"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" disable_release_ratchets "Disable release ratchets"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_empty_commit
assert_worktree_clean
)
test_ratchet_only_conflict_is_resolved() (
local repo="$test_root/ratchet-conflict"
init_repo "$repo"
local commit
commit=$(conflicting_ratchet_commit "$repo")
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_continuation_never_opens_an_editor() (
local repo="$test_root/editor"
init_repo "$repo"
local commit
commit=$(conflicting_ratchet_commit "$repo")
cd "$repo"
# Git only opens an editor for the continuation when stdin is a terminal, so run it on a pty. The
# exported GIT_EDITOR=false turns any editor launch into a failure. The single quotes are deliberate:
# $1 and $2 are for the inner bash, not this shell.
# shellcheck disable=SC2016
python3 -c 'import os, pty, sys; sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))' \
bash -c 'source "$1/kondo-ratchets.sh" && cherry_pick_backport "$2"' bash "$action_dir" "$commit" >/dev/null ||
fail "the cherry-pick continuation opened an editor"
assert_ratchets_disabled
assert_cherry_pick_completed
)
test_non_ratchet_conflict_remains_manual() (
local repo="$test_root/non-ratchet-conflict"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app "Change release app"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets "$active_ratchets"
assert_only_conflicts app.txt
assert_cherry_pick_pending
)
test_mixed_conflict_resolves_only_ratchets() (
local repo="$test_root/mixed-conflict"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app_and_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_only_conflicts app.txt
assert_cherry_pick_pending
)
test_release_cut_and_backport_commit_the_same_opt_out() (
local cut_repo="$test_root/release-cut"
init_repo "$cut_repo"
cd "$cut_repo"
git checkout -qb release-x.99.x
write_disabled_ratchets
git add --sparse .clj-kondo/ratchets.edn
git commit -q --allow-empty --no-verify -m "Cut release-x.99.x branch"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/release-cut.edn"
local backport_repo="$test_root/release-cut-backport"
init_repo "$backport_repo"
local commit
commit=$(feature_commit "$backport_repo" change_ratchets)
git -C "$backport_repo" checkout -q release
cd "$backport_repo"
cherry_pick_backport "$commit"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/backport.edn"
cmp -s "$test_root/release-cut.edn" "$test_root/backport.edn" ||
fail "the release cut and the backport committed different opt-out files"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut.edn")" \
"the release cut did not install the expected opt-out"
)
test_cut_release_workflow_uses_the_shared_writer() (
grep -Fq 'source .github/actions/create-backport/kondo-ratchets.sh' "$cut_release_workflow" ||
fail "the cut-release workflow does not source kondo-ratchets.sh"
grep -Fq 'write_disabled_ratchets' "$cut_release_workflow" ||
fail "the cut-release workflow does not call write_disabled_ratchets"
! grep -Fq ':disabled' "$cut_release_workflow" ||
fail "the cut-release workflow carries its own copy of the opt-out"
)
test_invalid_commit_failure_is_propagated() (
local repo="$test_root/invalid"
init_repo "$repo"
cd "$repo"
if cherry_pick_backport deadbeef; then
fail "an invalid commit succeeded"
fi
)
test_generated_script_resolves_ratchets_end_to_end() (
local repo="$test_root/generated-script"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app_and_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
cd "$repo"
cherry_pick_backport "$commit"
git cherry-pick --abort
local release_commit
release_commit=$(git rev-parse HEAD)
write_backport_script "$commit"
chmod +x backport.sh
git add backport.sh
git commit -qm "Add manual backport script"
bash -n backport.sh
grep -Fq 'write_disabled_ratchets ()' backport.sh || fail "the generated script omitted write_disabled_ratchets"
./backport.sh
assert_ratchets_disabled
assert_only_conflicts app.txt
assert_cherry_pick_pending
assert_eq "$release_commit" "$(git rev-parse HEAD)" "the generated script did not return HEAD to the release commit"
[ ! -e backport.sh ] || fail "the generated script did not remove itself"
! git ls-files --error-unmatch backport.sh >/dev/null 2>&1 || fail "the generated script is still tracked"
! git status --porcelain | grep -q backport.sh || fail "the generated script left a status entry"
)
# Test harness
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
local expected=$1
local actual=$2
local message=$3
[ "$expected" = "$actual" ] || fail "$message (expected '$expected', got '$actual')"
}
assert_ratchets() {
assert_eq "$1" "$(cat .clj-kondo/ratchets.edn)" "unexpected ratchet state"
}
assert_ratchets_disabled() {
assert_ratchets "$disabled_ratchets"
}
assert_cherry_pick_completed() {
! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick is still pending"
}
assert_cherry_pick_pending() {
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick state was lost"
}
assert_only_conflicts() {
local expected
local actual
expected=$(printf '%s\n' "$@" | sort)
actual=$(git diff --name-only --diff-filter=U | sort)
assert_eq "$expected" "$actual" "unexpected unresolved files"
}
assert_empty_commit() {
git diff --quiet HEAD^ HEAD || fail "the backport commit was not empty"
}
assert_worktree_clean() {
[ -z "$(git status --porcelain)" ] || fail "the backport left worktree changes"
}
init_repo() {
local repo=$1
git init -q -b release "$repo"
git -C "$repo" config user.email test@example.com
git -C "$repo" config user.name "Backport test"
printf 'base\n' > "$repo/app.txt"
mkdir -p "$repo/.clj-kondo"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets.edn"
git -C "$repo" add .
git -C "$repo" commit -qm "Base"
}
feature_commit() {
local repo=$1
shift
git -C "$repo" checkout -qb feature
"$@" "$repo"
git -C "$repo" add .
git -C "$repo" commit -qm "Feature"
git -C "$repo" rev-parse HEAD
}
# Sets up a release branch whose ratchets conflict with the feature commit, and prints that commit.
conflicting_ratchet_commit() {
local repo=$1
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_ratchets "Change release ratchets"
echo "$commit"
}
change_app() {
printf 'feature\n' > "$1/app.txt"
}
change_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_app_and_ratchets() {
change_app "$1"
change_ratchets "$1"
}
change_release_app() {
printf 'release\n' > "$1/app.txt"
}
change_release_ratchets() {
printf '{:ignore-counts {:release 1}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_release_app_and_ratchets() {
change_release_app "$1"
change_release_ratchets "$1"
}
# Setup writes the opt-out through the shared writer; the only copy is the expected text at the top.
disable_release_ratchets() {
(cd "$1" && write_disabled_ratchets)
}
commit_release_change() {
local repo=$1
local change=$2
local message=$3
"$change" "$repo"
git -C "$repo" add .
git -C "$repo" commit -qm "$message"
}
run_test() {
local description=$1
local test=${description// /_}
test=${test//-/_}
test="test_$test"
declare -F "$test" >/dev/null || fail "missing test function: $test"
printf 'TEST: %s\n' "$description"
"$test"
printf 'PASS: %s\n' "$description"
}
run_test "unrelated backport preserves target ratchets"
run_test "clean ratchet backport installs release opt-out"
run_test "disabled release absorbs ratchet-only backport"
run_test "ratchet-only conflict is resolved"
run_test "continuation never opens an editor"
run_test "non-ratchet conflict remains manual"
run_test "mixed conflict resolves only ratchets"
run_test "release cut and backport commit the same opt-out"
run_test "cut-release workflow uses the shared writer"
run_test "invalid commit failure is propagated"
run_test "generated script resolves ratchets end-to-end"

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
# The only place that knows what a release branch's opt-out looks like. The cut-release workflow, the
# backport helper, and the tests all go through this function.
write_disabled_ratchets() {
mkdir -p .clj-kondo
printf '%s\n' \
';; Kondo ignore ratchets apply only to master; this release branch opts out.' \
';; The test and fixer recognize this explicit opt-out.' \
'{:disabled true}' \
> .clj-kondo/ratchets.edn
}
disable_ratchets() {
local commit=$1
local file=.clj-kondo/ratchets.edn
[ -n "$(git diff-tree --root --no-commit-id --name-only -r "$commit" -- "$file")" ] || return 0
echo "$file: disabling kondo ratchets on the release branch"
write_disabled_ratchets
git add -- "$file"
if ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
if ! git diff --cached --quiet HEAD; then
git commit --amend --no-edit
fi
return 0
fi
if [ -z "$(git ls-files -u)" ]; then
echo "$file was the only conflict, finishing the cherry-pick"
if git diff --cached --quiet HEAD; then
echo "nothing left to apply, committing the backport empty"
git commit --allow-empty --no-edit
else
# GIT_EDITOR outranks core.editor, VISUAL, and EDITOR, so the continuation can never wait on
# an editor; the repository's own editor configuration is left alone.
GIT_EDITOR=true git cherry-pick --continue
fi
fi
}
cherry_pick_backport() {
local commit=$1
local status=0
git cherry-pick "$commit" || status=$?
if [ "$status" -ne 0 ] && ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
echo "cherry-pick failed without entering a resolvable conflict state" >&2
return "$status"
fi
disable_ratchets "$commit"
if [ "$status" -ne 0 ] &&
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null &&
[ -z "$(git ls-files -u)" ]; then
echo "cherry-pick stopped without a file conflict to resolve" >&2
return "$status"
fi
}
write_backport_script() {
local commit=$1
{
echo "#!/usr/bin/env bash"
echo "set -euo pipefail"
echo "git reset HEAD~1"
echo "rm ./backport.sh"
declare -f write_disabled_ratchets disable_ratchets cherry_pick_backport
printf 'cherry_pick_backport %q\n' "$commit"
printf '%s\n' "printf 'Resolve conflicts and force push this branch.\\n\\nTo backport translations run: bin/i18n/merge-translations <release-branch>\\n'"
} > ./backport.sh
}

View File

@@ -1,5 +1,5 @@
name: Create test plan
description: Cruise the frontend import graph and compute the affected-tests plan.
description: Compute the affected-tests plan for the changed files.
inputs:
changed-files:
@@ -28,21 +28,15 @@ outputs:
stats:
description: FE Affected Tests stats row (JSON).
value: ${{ steps.compute.outputs.stats }}
fe_unit_specs_to_run:
description: Unit specs to run (JSON array).
value: ${{ steps.compute.outputs.fe_unit_specs_to_run }}
loki_stories_to_run:
description: Loki stories to run (JSON array).
value: ${{ steps.compute.outputs.loki_stories_to_run }}
e2e_specs_to_run:
description: E2E specs to run (JSON array).
value: ${{ steps.compute.outputs.e2e_specs_to_run }}
artifact-id:
description: Artifact containing test-plan.json for this run.
value: ${{ steps.upload.outputs.artifact-id }}
runs:
using: composite
steps:
- name: Prepare Node + Bun
uses: ./.github/actions/prepare-node-bun
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Resolve closest-ancestor e2e coverage manifest
id: manifest
uses: actions/github-script@v9
@@ -61,16 +55,6 @@ runs:
headSha: context.payload.pull_request?.head?.sha ?? context.sha,
});
core.setOutput("path", result?.path ?? "");
- name: Build dependency graph
shell: bash
run: |
# Quiet on success; surface output only if install fails.
bun install --frozen-lockfile > /tmp/bun-install.log 2>&1 \
|| { echo "bun install issues (continuing):"; tail -20 /tmp/bun-install.log; }
bunx depcruise frontend/src enterprise/frontend/src \
--config .dependency-cruiser.cjs \
--output-type json > dependency-graph.json || true
- name: Create test plan
id: compute
shell: bash
@@ -82,15 +66,20 @@ runs:
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
DEP_GRAPH_JSON: dependency-graph.json
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
run: |
# stdout is the plan JSON; the planner's own info logs go to stderr.
# The planner writes this step's GITHUB_OUTPUT entries.
bun .github/scripts/create-test-plan.ts > test-plan.json
echo "stats=$(jq -c '.stats' test-plan.json)" >> "$GITHUB_OUTPUT"
echo "fe_unit_specs_to_run=$(jq -c '.fe_unit_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
echo "loki_stories_to_run=$(jq -c '.loki_stories_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
echo "e2e_specs_to_run=$(jq -c '.e2e_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
- name: Upload test plan
id: upload
uses: actions/upload-artifact@v7
with:
name: test-plan-${{ github.run_id }}-${{ github.run_attempt }}
path: test-plan.json
if-no-files-found: error
retention-days: 7
- name: Report test plan
shell: bash
@@ -110,6 +99,10 @@ runs:
echo
echo "Changed: $(jq '.stats.fe_files_changed' "$plan") FE / $(jq '.stats.be_files_changed' "$plan") BE file(s) · $(jq '.stats.fe_modules_affected_usage' "$plan") FE module(s) affected"
echo
if [ "$(jq '.stats.loki_preview_touched' "$plan")" = "true" ]; then
echo "**Loki: full run**: a file the Storybook preview imports changed."
echo
fi
e2e_run=$(jq '.stats.e2e_specs_to_run_usage' "$plan")
e2e_all=$(jq '.stats.e2e_specs_all' "$plan")
if [ "$e2e_run" -eq "$e2e_all" ]; then

View File

@@ -1,4 +1,7 @@
name: Cross-branch migration test
description: >-
Boot Metabase on the base branch to migrate the app db, then boot the PR branch against the
same db, verifying the PR's migrations apply cleanly on top of the base branch's schema.
inputs:
base-ref: ## Base branch PR came from

View File

@@ -15,6 +15,10 @@ inputs:
description: OpenLDAP container
required: true
default: false
mocksaml:
description: Mock SAML container
required: true
default: false
postgres:
description: Postgres container
required: true
@@ -73,6 +77,7 @@ runs:
[ "${{ inputs.mongo }}" = true ] && SERVICES+=(mongo-sample)
[ "${{ inputs.maildev }}" = true ] && SERVICES+=(maildev maildev-ssl)
[ "${{ inputs.openldap }}" = true ] && SERVICES+=(ldap)
[ "${{ inputs.mocksaml }}" = true ] && SERVICES+=(saml-idp)
[ "${{ inputs.webhook }}" = true ] && SERVICES+=(webhook-tester)
[ "${{ inputs.snowplow }}" = true ] && SERVICES+=(snowplow-micro)
@@ -93,8 +98,8 @@ runs:
if ${{ inputs.maildev }}; then
echo -e "${Y}checking maildev container..." &&
while ! nc -z localhost 1080; do sleep 1; done &&
while ! nc -z localhost 1025; do sleep 1; done &&
while ! nc -z localhost 1180; do sleep 1; done &&
while ! nc -z localhost 1125; do sleep 1; done &&
echo -e "${G}Maildev is up and running!"
echo -e "${Y}checking maildev-ssl container..." &&
@@ -109,6 +114,12 @@ runs:
echo -e "${G}Openldap is up and running!"
fi
if ${{ inputs.mocksaml }}; then
echo -e "${Y}checking mock saml container..." &&
while ! nc -z localhost 8090; do sleep 1; done &&
echo -e "${G}Mock SAML is up and running!"
fi
if ${{ inputs.postgres }}; then
echo -e "${Y}checking postgres container..." &&
while ! nc -z localhost 5404; do sleep 1; done &&

View File

@@ -20,6 +20,15 @@ inputs:
description: Directory path where the jar should be extracted (defaults to current directory)
required: false
default: "."
download:
description: Default is true. Set to false to only look the artifact up and report `fetch-hit`.
required: false
default: "true"
outputs:
fetch-hit:
description: Boolean indicating if an artifact was successfully found and downloaded.
value: ${{ steps.find_release_artifact.outputs.fetch-hit }}
runs:
using: "composite"
@@ -28,11 +37,12 @@ runs:
id: find_release_artifact
uses: actions/github-script@v9
with:
result-encoding: string
script: | # js
const fs = require('fs');
const isRelease = "${{ inputs.release }}" === "true";
const failOnMiss = "${{ inputs.fail-on-fetch-miss }}" !== "false";
const shouldDownload = "${{ inputs.download }}" !== "false";
const prefix = isRelease ? "metabase-release" : "metabase";
const artifactName = `${prefix}-${{ inputs.edition }}-${{ inputs.commit }}-uberjar`;
console.log(`Looking for artifact: ${artifactName}`);
@@ -44,12 +54,18 @@ runs:
per_page: 1,
});
if (!artifacts.data?.artifacts?.[0]?.id) {
core.setFailed(`No artifacts found for ${artifactName}`);
return;
const artifact_id = artifacts.data?.artifacts?.[0]?.id;
if (!artifact_id) {
core.setOutput('fetch-hit', 'false');
} else {
core.setOutput('fetch-hit', 'true');
}
const artifact_id = artifacts.data.artifacts[0].id;
if (!shouldDownload) {
console.log(`skipping download (download: false)`);
return;
}
let download;
const maxAttempts = 3;
@@ -74,6 +90,7 @@ runs:
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/mb.zip`, Buffer.from(download.data));
- name: Extract and rename JAR
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
shell: bash
run: | # bash
OUTPUT_NAME="${{ inputs.output-name }}"
@@ -113,18 +130,20 @@ runs:
# Set the JAR_PATH environment variable
if [ "$EXTRACT_PATH" == "." ]; then
echo "JAR_PATH=$OUTPUT_NAME" >> $GITHUB_ENV
echo "JAR_PATH=$OUTPUT_NAME" >> "$GITHUB_ENV"
else
echo "JAR_PATH=$EXTRACT_PATH/$OUTPUT_NAME" >> $GITHUB_ENV
echo "JAR_PATH=$EXTRACT_PATH/$OUTPUT_NAME" >> "$GITHUB_ENV"
fi
- name: Verify that this is a valid JAR file
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
shell: bash
run: |
echo "Verifying JAR at: $JAR_PATH"
file --mime-type "$JAR_PATH"
- name: Reveal its version.properties
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
shell: bash
run: |
echo "Extracting version.properties from: $JAR_PATH"

View File

@@ -22,7 +22,7 @@ runs:
COMMIT=$(env -i git log $BASE_REF --grep="(#$PULL_REQUEST_NUMBER)" --format="%H")
echo "commit SHA $COMMIT"
echo "commit=$COMMIT" >> $GITHUB_OUTPUT
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
id: find-squashed-commit
shell: bash
env:

View File

@@ -1,45 +0,0 @@
name: Get cache keys
description: |
Get deterministic cache keys for Maven and Node.js caches based on the centrally controlled cache prefix,
and the suffix based on the runner OS and the current week of the year.
outputs:
m2-cache-key:
description: "The cache key for the Maven cache."
value: ${{ steps.keys-factory.outputs.m2-cache-key }}
m2-restore-key:
description: "The fallback restore key prefix for the Maven cache."
value: ${{ steps.keys-factory.outputs.m2-restore-key }}
node-cache-key:
description: "The cache key for the Node.js cache."
value: ${{ steps.keys-factory.outputs.node-cache-key }}
node-restore-key:
description: "The fallback restore key prefix for the Node.js cache."
value: ${{ steps.keys-factory.outputs.node-restore-key }}
eslint-cache-key:
description: "The cache key for Eslint cache"
value: ${{ steps.keys-factory.outputs.eslint-cache-key }}
cypress-cache-key:
description: "The cache key for Cypress cache"
value: ${{ steps.keys-factory.outputs.cypress-cache-key }}
runs:
using: "composite"
steps:
- name: Generate and output cache keys
id: keys-factory
run: |
M2_CACHE_PREFIX='M2'
NODE_CACHE_PREFIX='node-modules'
ESLINT_CACHE_PREFIX='eslint'
CYPRESS_CACHE_PREFIX='cypress'
WEEK_OF_YEAR=$(/bin/date "+%G-%V")
CACHE_SUFFIX="${{ runner.os }}-$WEEK_OF_YEAR"
echo "m2-cache-key=$M2_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
echo "m2-restore-key=$M2_CACHE_PREFIX-" >> $GITHUB_OUTPUT
echo "node-cache-key=$NODE_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
echo "node-restore-key=$NODE_CACHE_PREFIX-" >> $GITHUB_OUTPUT
echo "eslint-cache-key=$ESLINT_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
echo "cypress-cache-key=$CYPRESS_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
shell: bash

View File

@@ -1,7 +1,19 @@
name: Get Docker Info
description: Determines Docker repository and tag based on branch and edition
description: Determines the registry, repository, and tag a container image is pushed to.
inputs:
registry:
description: Registry the image is pushed to, either "docker" or "ecr".
required: false
default: docker
repo:
description: Repository name, e.g. "metabase-enterprise". Overrides a branch derived repo.
required: false
default: ''
tag:
description: Image tag, e.g. v1.87.2. Overrides a branch derived tag.
required: false
default: ''
branch:
description: Branch name (defaults to current branch)
required: false
@@ -10,14 +22,22 @@ inputs:
description: Edition (ee or oss)
required: false
default: ee
owner:
description: Repository owner (defaults to current repo owner)
aws-account-id:
description: AWS account id hosting the ECR registry. Required for the ecr registry.
required: false
default: ${{ github.repository_owner }}
default: ''
aws-region:
description: Region of the ECR registry.
required: false
default: us-east-1
dockerhub-owner:
description: DockerHub owner.
required: false
default: metabase
outputs:
repo:
description: Docker repository name
description: Docker repository, including the registry host (ecr) or the owner (docker)
value: ${{ steps.docker-info.outputs.repo }}
tag:
description: Docker tag
@@ -25,9 +45,6 @@ outputs:
full_image:
description: Full Docker image name (repo:tag)
value: ${{ steps.docker-info.outputs.full_image }}
editions:
description: JSON array of editions to build
value: ${{ steps.docker-info.outputs.editions }}
runs:
using: "composite"
@@ -35,40 +52,49 @@ runs:
- name: Determine Docker info
id: docker-info
uses: actions/github-script@v9
env:
REGISTRY: ${{ inputs.registry }}
REPO: ${{ inputs.repo }}
TAG: ${{ inputs.tag }}
BRANCH: ${{ inputs.branch }}
EDITION: ${{ inputs.edition }}
AWS_ACCOUNT_ID: ${{ inputs.aws-account-id }}
AWS_REGION: ${{ inputs.aws-region }}
DOCKERHUB_OWNER: ${{ inputs.dockerhub-owner }}
with:
script: | # js
const branch = '${{ inputs.branch }}';
const edition = '${{ inputs.edition }}';
const owner = '${{ inputs.owner }}';
const { REGISTRY, OWNER, BRANCH, EDITION, AWS_ACCOUNT_ID, AWS_REGION, DOCKERHUB_OWNER } = process.env;
console.log(`Determining Docker info for branch: ${branch}, edition: ${edition}`);
let repo, tag, editions;
if (branch === 'master') {
// Master builds both editions with specific repos
editions = ['ee', 'oss'];
tag = 'latest';
if (edition === 'ee') {
repo = `${owner}/metabase-enterprise-head`;
} else {
repo = `${owner}/metabase-head`;
}
} else {
// All other branches only build EE to metabase-dev
editions = ['ee'];
tag = branch.replaceAll('/', '-'); // slashes make docker sad
repo = `${owner}/metabase-dev`;
let tag = process.env.TAG;
if (!tag) {
tag = BRANCH === 'master' ? 'latest' : `${BRANCH.replaceAll('/', '-')}-${EDITION}`;
}
const fullImage = `${repo}:${tag}`;
let name = process.env.REPO;
if (!name) {
if (BRANCH === 'master') {
name = EDITION === 'ee' ? 'metabase-enterprise-head' : 'metabase-head';
} else {
name = 'metabase-dev';
}
}
let repo;
if (REGISTRY === 'ecr') {
if (!AWS_ACCOUNT_ID) {
core.setFailed('Pushing to AWS ECR requires aws-account-id to be set.');
return;
}
repo = `${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${name}`;
} else if (REGISTRY === 'docker') {
repo = `${DOCKERHUB_OWNER}/${name}`;
} else {
core.setFailed(`Unknown registry "${REGISTRY}", expected "docker" or "ecr".`);
return;
}
// Set outputs
core.setOutput('repo', repo);
core.setOutput('tag', tag);
core.setOutput('full_image', fullImage);
core.setOutput('editions', JSON.stringify(editions));
core.setOutput('full_image', `${repo}:${tag}`);
console.log(`Docker image: ${fullImage}`);
console.log(`Editions: ${JSON.stringify(editions)}`);
console.log(`Resolved: registry = ${REGISTRY}, repo = ${repo}, tag = ${tag}`);

View File

@@ -1,12 +1,11 @@
name: Notify Pull Request
description: Post a comment on the pull request, optionally replacing the previous one with the same message.
inputs:
remove-previous-comment:
required: false
type: boolean
default: false
add-new-comment:
required: false
type: boolean
default: true
message:
required: true

View File

@@ -5,9 +5,6 @@ inputs:
java-version:
required: true
default: "25"
clojure-version:
required: true
default: "1.12.0.1488"
runs:
using: "composite"
@@ -17,12 +14,40 @@ runs:
with:
java-version: ${{ inputs.java-version }}
distribution: "temurin"
- name: Cache spec
id: cache-spec
shell: bash
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
- name: Restore Clojure CLI
uses: actions/cache/restore@v5
with:
path: ${{ steps.cache-spec.outputs.clojure-path }}
key: ${{ steps.cache-spec.outputs.clojure-key }}
- name: Install Clojure CLI
shell: bash
env:
CLOJURE_PREFIX: ${{ steps.cache-spec.outputs.clojure-path }}
CLOJURE_VERSION: ${{ steps.cache-spec.outputs.clojure-version }}
run: |
set -uo pipefail
echo "$CLOJURE_PREFIX/bin" >> "$GITHUB_PATH"
export PATH="$CLOJURE_PREFIX/bin:$PATH"
# Whether the prefix already holds the right install is decided by running it, not by the
# cache-hit flag. The installer bakes the prefix into the clj/clojure shims, so a tree restored
# under a different $HOME points at a directory that is not there; and on a runner that is not
# ephemeral, a previous version's prefix outlives the cache entry that stopped matching it.
if clojure --version | grep -qF "$CLOJURE_VERSION"; then
clojure --version
exit 0
fi
cd "$RUNNER_TEMP"
for attempt in 1 2 3; do
curl --retry 3 --retry-delay 5 --fail -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh \
&& sudo bash ./linux-install-${{ inputs.clojure-version }}.sh \
curl --retry 3 --retry-delay 5 --fail -O "https://download.clojure.org/install/linux-install-$CLOJURE_VERSION.sh" \
&& bash "./linux-install-$CLOJURE_VERSION.sh" --prefix "$CLOJURE_PREFIX" \
&& break
echo "::warning::Clojure install attempt $attempt failed, retrying in 10s..."
sleep 10
@@ -32,10 +57,6 @@ runs:
exit 1
fi
- name: Get M2 cache key
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
- name: Install uv (for Python package installation)
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
@@ -46,9 +67,6 @@ runs:
- name: Restore M2 cache
uses: actions/cache/restore@v5
with:
path: |
~/.m2
~/.gitlibs
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
restore-keys: |
${{ steps.get-cache-keys.outputs.m2-restore-key }}
path: ${{ steps.cache-spec.outputs.m2-path }}
key: ${{ steps.cache-spec.outputs.m2-key }}
restore-keys: ${{ steps.cache-spec.outputs.m2-restore-key }}

View File

@@ -25,15 +25,17 @@ runs:
chrome-version: 144
id: setup-chrome
- name: Get cache keys
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
- name: Cache spec
id: cache-spec
shell: bash
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
- name: Get Cypress cache
- name: Restore Cypress binary
uses: actions/cache/restore@v5
with:
path: ~/.cache/Cypress
key: ${{ steps.get-cache-keys.outputs.cypress-cache-key }}
path: ${{ steps.cache-spec.outputs.cypress-path }}
key: ${{ steps.cache-spec.outputs.cypress-key }}
restore-keys: ${{ steps.cache-spec.outputs.cypress-restore-key }}
- name: Ensure that Cypress executable is ready
run: |

View File

@@ -16,43 +16,37 @@ runs:
with:
node-version: ${{ inputs.node-version }}
- name: Get cache keys
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
- name: Restore M2 cache
uses: actions/cache/restore@v5
with:
path: |
~/.m2
~/.gitlibs
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
restore-keys: |
${{ steps.get-cache-keys.outputs.m2-restore-key }}
- name: Get bun cache directory
id: bun-cache
run: |
BUN_CACHE_DIR=$(bun pm cache)
echo "Bun cache directory: $BUN_CACHE_DIR"
echo "dir=$BUN_CACHE_DIR" >> $GITHUB_OUTPUT
- name: Cache spec
id: cache-spec
shell: bash
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
- name: Restore node_modules cache
# The spec names the store path rather than discovering it, so that a job can restore the cache
# before bun exists. Exported job-wide rather than per-step because every later bun invocation has
# to resolve against the same store. Confirm bun agreed: if the variable stopped taking effect, bun
# would fill its default directory and the cache would silently save an empty tree.
- name: Point bun at the cached store
shell: bash
env:
STORE: ${{ steps.cache-spec.outputs.bun-store-path }}
run: |
echo "BUN_INSTALL_CACHE_DIR=$STORE" >> "$GITHUB_ENV"
ACTUAL=$(BUN_INSTALL_CACHE_DIR="$STORE" bun pm cache)
if [ "$ACTUAL" != "$STORE" ]; then
echo "::error::bun reports its store at $ACTUAL, not $STORE; BUN_INSTALL_CACHE_DIR is not taking effect"
exit 1
fi
- name: Restore bun store
uses: actions/cache/restore@v5
id: node-modules-cache
with:
path: |
${{ steps.bun-cache.outputs.dir }}
node_modules
key: ${{ steps.get-cache-keys.outputs.node-cache-key }}
restore-keys: |
${{ steps.get-cache-keys.outputs.node-restore-key }}
path: ${{ steps.cache-spec.outputs.bun-store-path }}
key: ${{ steps.cache-spec.outputs.bun-store-key }}
restore-keys: ${{ steps.cache-spec.outputs.bun-store-restore-key }}
# postinstall lifecycle hook can be dangerous in CI so we only run it locally (see package.json)
# If a job wants to patch packages, it does so explicitly in the CI. This setting is opt-out.
# For example, we never want to cache node_modules with patches already applied so cache-generator.yml skips that step.
- name: Clean install
- name: Install front-end dependencies
run: bun install --frozen-lockfile
shell: bash
- name: Patch frontend dependencies

View File

@@ -14,21 +14,6 @@ inputs:
staging-mb-all-features-token:
description: "Staging MB all features token"
required: true
shoppy-iam-role:
description: "Shoppy IAM role for AWS credentials"
required: true
shoppy-metabase-app-db-s3-bucket:
description: "S3 bucket for Shoppy app DB dump"
required: true
shoppy-metabase-app-db-s3-key:
description: "S3 key for Shoppy app DB dump"
required: true
shoppy-metabase-app-db-user:
description: "Shoppy Metabase app DB user"
required: true
shoppy-metabase-app-db-database:
description: "Shoppy Metabase app DB database"
required: true
metastore-dev-server-url:
description: "Metastore dev server URL"
required: true
@@ -88,25 +73,11 @@ runs:
REPO_URL="https://github.com/metabase/shoppy.git"
echo "Checking if branch '${SAMPLE_APP_BRANCH_NAME}' exists in ${REPO_URL}..."
if git ls-remote --exit-code --heads "$REPO_URL" "${SAMPLE_APP_BRANCH_NAME}"; then
echo "sample_app_branch_exists=true" >> $GITHUB_OUTPUT
echo "sample_app_branch_exists=true" >> "$GITHUB_OUTPUT"
else
echo "sample_app_branch_exists=false" >> $GITHUB_OUTPUT
echo "sample_app_branch_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Configure AWS credentials
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885
with:
role-to-assume: ${{ inputs.shoppy-iam-role }}
role-session-name: GitHub_to_AWS_via_FederatedOIDC
aws-region: us-east-1
- name: Download Shoppy's App DB PostgreSQL dump from S3
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
shell: bash
run: |
aws s3 cp s3://${{ inputs.shoppy-metabase-app-db-s3-bucket }}/${{ inputs.shoppy-metabase-app-db-s3-key }} ./e2e/tmp/db_dumps/shoppy_metabase_app_db_dump.sql
- name: Prepare and launch Shoppy containers
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
shell: bash
@@ -115,8 +86,6 @@ runs:
SAMPLE_APP_ENVIRONMENT: ${{ inputs.sample-app-environment }}
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ inputs.staging-mb-all-features-token }}
METASTORE_DEV_SERVER_URL: ${{ inputs.metastore-dev-server-url }}
METABASE_APP_DB_USER: ${{ inputs.shoppy-metabase-app-db-user }}
METABASE_APP_DB: ${{ inputs.shoppy-metabase-app-db-database }}
SHOPPY_DATADOG_APPLICATION_ID: ${{ inputs.shoppy-datadog-application-id }}
SHOPPY_DATADOG_CLIENT_TOKEN: ${{ inputs.shoppy-datadog-client-token }}
SHOPPY_DATADOG_SITE: "us5.datadoghq.com"

View File

@@ -0,0 +1,39 @@
name: Prepare test selection
description: Download this run's test plan and write the suite's selected test paths.
inputs:
suite:
description: The suite to select tests for (unit, loki or e2e).
required: true
test-plan-artifact-id:
description: ID of this run's test-plan.json artifact.
required: true
require-download:
description: Fail the step when the test plan download fails, instead of running the full suite.
required: false
default: "false"
outputs:
paths-file:
description: JSON file listing the selected test paths, or '' when the suite runs in full.
value: ${{ steps.selection.outputs.paths-file }}
selection:
description: full, narrowed or empty, or '' when the plan is unavailable and the suite runs in full.
value: ${{ steps.selection.outputs.selection }}
runs:
using: composite
steps:
- name: Download test plan
id: test-plan
continue-on-error: ${{ inputs.require-download != 'true' }}
uses: actions/download-artifact@v8
with:
artifact-ids: ${{ inputs.test-plan-artifact-id }}
path: ${{ runner.temp }}/test-plan
- name: Prepare test selection
id: selection
shell: bash
env:
PLAN_DOWNLOADED: ${{ steps.test-plan.outcome }}
run: bun .github/scripts/prepare-test-selection.ts "${{ inputs.suite }}"

View File

@@ -1,26 +0,0 @@
name: Prepare Uberjar Artifact
description: Prepare and upload Metabase JAR as a GitHub artifact.
inputs:
edition:
required: false
default: "ee"
description: Metabase edition (ee or oss)
runs:
using: "composite"
steps:
- name: Mark with the commit hash
run: git rev-parse --short HEAD > COMMIT-ID
shell: bash
- name: Calculate SHA256 checksum
run: sha256sum ./target/uberjar/metabase.jar > SHA256.sum
shell: bash
- name: Upload JARs as artifact
uses: actions/upload-artifact@v7
with:
name: metabase-${{ inputs.edition }}-${{ github.event.pull_request.head.sha || github.sha }}-uberjar
path: |
./target/uberjar/metabase.jar
./COMMIT-ID
./SHA256.sum

View File

@@ -25,6 +25,7 @@ runs:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }}
CI_CONDUCTOR_TEST_SUITE: ${{ inputs.test-suite }}
ADAPTER: ${{ inputs.adapter }}
IS_RERUN: ${{ github.run_attempt != '1' }}
# Keep the path constrained to known entrypoints.
run: |
case "$ADAPTER" in

View File

@@ -1,100 +0,0 @@
name: Embedding SDK Bundle Size Check
description: |
Measures app + embedding SDK bundle sizes from the current and base-ref
uberjars (reusing the builds that already ran) plus their bundle-stats
artifacts, and flags changes to the chunked SDK's reachable, gzipped size
beyond the threshold. Skips when the base-ref artifacts aren't available
rather than rebuilding from source (which would compare across pipelines).
inputs:
edition:
description: 'Metabase edition (ee or oss)'
required: true
base-ref-commit:
description: 'Commit SHA of the base ref.'
required: true
threshold-percent:
description: 'Percent threshold for bundle size change'
required: true
outputs:
status:
description: "Bundle size status: increased, decreased, or stable"
value: ${{ steps.compare.outputs.status }}
size_change_percent:
description: "size change percent (positive = increase, negative = decrease)"
value: ${{ steps.compare.outputs.size_change_percent }}
runs:
using: "composite"
steps:
- name: Download current uberjar
uses: ./.github/actions/fetch-artifact
with:
commit: ${{ github.event.pull_request.head.sha || github.sha }}
edition: ${{ inputs.edition }}
extract-path: temp/current-uberjar
- name: Download base-ref uberjar
id: base-jar
continue-on-error: true
uses: ./.github/actions/fetch-artifact
with:
commit: ${{ inputs.base-ref-commit }}
edition: ${{ inputs.edition }}
extract-path: temp/base-uberjar
- name: Measure and compare bundle sizes
id: compare
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EDITION: ${{ inputs.edition }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
BASE_SHA: ${{ inputs.base-ref-commit }}
THRESHOLD: ${{ inputs.threshold-percent }}
MEASURE_BUNDLES: app,embedding-sdk-legacy,embedding-sdk-chunked
run: |
set -uo pipefail
skip() {
echo "::notice::Embedding SDK bundle-size check skipped: $1"
echo "status=stable" >> "$GITHUB_OUTPUT"
echo "size_change_percent=0" >> "$GITHUB_OUTPUT"
exit 0
}
# The base ref needs an already-built uberjar. Rebuilding it from source
# would compare across two build pipelines (the bug this check used to
# have), so skip instead when it — or its stats — isn't available.
[ -f temp/base-uberjar/metabase.jar ] || skip "base-ref uberjar not found for ${BASE_SHA}"
extract_js() { # $1=jar $2=dest-root
mkdir -p "$2/resources"
unzip -o "$1" \
"frontend_client/app/dist/*" \
"frontend_client/app/embed.js" \
"frontend_client/app/embedding-sdk/*" \
-d "$2/resources" >/dev/null || true
}
fetch_stats() { # $1=sha $2=dest-dir
local name="metabase-bundle-stats-${EDITION}-$1"
local id
id=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${name}" --jq '.artifacts[0].id // empty' 2>/dev/null)
[ -n "${id}" ] || return 1
mkdir -p "$2"
gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}/zip" > "$2.zip" 2>/dev/null || return 1
unzip -o "$2.zip" -d "$2" >/dev/null || return 1
}
extract_js temp/current-uberjar/metabase.jar temp/current
extract_js temp/base-uberjar/metabase.jar temp/base
fetch_stats "${HEAD_SHA}" temp/current/artifacts || skip "bundle stats not found for current ${HEAD_SHA}"
fetch_stats "${BASE_SHA}" temp/base/artifacts || skip "bundle stats not found for base ${BASE_SHA}"
MEASURE_ROOT=temp/current node ./.github/scripts/measure-bundle-sizes.js > temp/current-sizes.json \
|| { echo "::error::Failed to measure current bundle sizes"; exit 1; }
MEASURE_ROOT=temp/base node ./.github/scripts/measure-bundle-sizes.js > temp/base-sizes.json \
|| { echo "::error::Failed to measure base bundle sizes"; exit 1; }
node ./.github/actions/sdk-bundle-size-check/compare.js temp/current-sizes.json temp/base-sizes.json

View File

@@ -1,110 +0,0 @@
/* eslint-disable import/no-commonjs */
// Diffs the bundle sizes produced by measure-bundle-sizes.js for the current
// build and the base ref. Every (bundle, kind) is compared strictly against the
// same (bundle, kind) — never one bundle type against another — using gzipped
// (served) bytes. The gate is driven by the chunked SDK's reachable total, which
// is what the SDK actually delivers.
//
// compareBundles is pure (data in, data out) so it can be unit-tested; main()
// is the thin I/O wrapper that reads the size files, prints the report and sets
// the step outputs / exit code.
const fs = require("fs");
const pick = (rows, bundle, kind) => rows.find(row => row.bundle === bundle && row.kind === kind);
const keyOf = row => `${row.bundle}/${row.kind}`;
const mb = bytes => (bytes / 1024 / 1024).toFixed(2);
const percentOf = (currentBytes, baseBytes) =>
baseBytes ? Math.trunc(((currentBytes - baseBytes) * 100) / baseBytes) : 0;
/**
* Compare two measurement sets and decide the gate. Returns a human-readable
* `report` (always), and exactly one of:
* - `gate` { status, percent }: the chunked SDK total moved by `percent`.
* - `skip` reason: the gate is non-actionable (report stable, no comment).
* - `error` reason: the gate inputs are missing (hard failure).
*/
function compareBundles({ current, base, threshold }) {
const report = ["=== Bundle sizes (gzipped, current vs base) ==="];
const keys = [...new Set([...current, ...base].map(keyOf))].sort();
for (const key of keys) {
const [bundle, kind] = key.split("/");
const currentRow = pick(current, bundle, kind);
const baseRow = pick(base, bundle, kind);
if (!currentRow || !baseRow) {
report.push(`${key}: present only in ${currentRow ? "current" : "base"} build`);
continue;
}
const diff = currentRow.gzipBytes - baseRow.gzipBytes;
const percent = percentOf(currentRow.gzipBytes, baseRow.gzipBytes);
report.push(`${key}: ${mb(currentRow.gzipBytes)}MB vs ${mb(baseRow.gzipBytes)}MB (${percent}%, ${diff} bytes)`);
}
// Gate on the chunked SDK's reachable total (gzipped) — what the SDK ships.
const gateCurrent = pick(current, "embedding-sdk-chunked", "total");
const gateBase = pick(base, "embedding-sdk-chunked", "total");
if (!gateCurrent || !gateBase || !gateBase.gzipBytes) {
return { report, error: "Could not find embedding-sdk-chunked total (gzip) in both builds" };
}
// Both sides must measure "total" the same way. A base ref built before the
// reachableAssets enrichment collapses its total to the initial set, so
// comparing it against the current reachable total reports a phantom jump
// (~30%). Skip until the base ref also carries reachable stats (i.e. once
// this change is on the base).
if (Boolean(gateCurrent.reachable) !== Boolean(gateBase.reachable)) {
return {
report,
skip:
`chunked "total" measured differently on each side ` +
`(current reachable=${Boolean(gateCurrent.reachable)}, base reachable=${Boolean(gateBase.reachable)}); ` +
`the base ref predates the reachable-chunk stats. Resolves once this change is on the base ref.`,
};
}
const percent = percentOf(gateCurrent.gzipBytes, gateBase.gzipBytes);
const status = percent > threshold ? "increased" : percent < -threshold ? "decreased" : "stable";
return { report, gate: { status, percent } };
}
module.exports = { compareBundles };
const setOutput = (name, value) => {
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
function main() {
const [, , currentPath, basePath] = process.argv;
const threshold = Number(process.env.THRESHOLD ?? 2);
const current = JSON.parse(fs.readFileSync(currentPath, "utf8"));
const base = JSON.parse(fs.readFileSync(basePath, "utf8"));
const { report, gate, skip, error } = compareBundles({ current, base, threshold });
console.log(report.join("\n"));
if (error) {
console.error(`::error::${error}`);
process.exit(1);
}
if (skip) {
// Treat the gate as non-actionable: report stable so no regression comment
// is posted, mirroring the shell skip() for missing artifacts.
console.log(`::notice::Embedding SDK bundle-size gate skipped: ${skip}`);
setOutput("status", "stable");
setOutput("size_change_percent", "0");
process.exit(0);
}
console.log("");
console.log(`Gate: embedding-sdk-chunked total ${gate.percent}% (threshold ${threshold}%) → ${gate.status}`);
setOutput("status", gate.status);
setOutput("size_change_percent", gate.percent);
}
if (require.main === module) {
main();
}

View File

@@ -1,65 +0,0 @@
const { compareBundles } = require("./compare");
const total = (gzipBytes, over = {}) => ({
bundle: "embedding-sdk-chunked",
kind: "total",
gzipBytes,
reachable: true,
...over,
});
describe("compareBundles", () => {
it("gates on the chunked SDK total and flags an increase past the threshold", () => {
const { gate, report } = compareBundles({
current: [total(110)],
base: [total(100)],
threshold: 2,
});
expect(gate).toEqual({ status: "increased", percent: 10 });
expect(report[0]).toContain("current vs base");
});
it("flags a decrease and stays stable within the threshold", () => {
expect(compareBundles({ current: [total(90)], base: [total(100)], threshold: 2 }).gate).toEqual({
status: "decreased",
percent: -10,
});
expect(compareBundles({ current: [total(101)], base: [total(100)], threshold: 2 }).gate).toEqual({
status: "stable",
percent: 1,
});
});
it("skips when the two sides measured total differently (reachable mismatch)", () => {
const result = compareBundles({
current: [total(130, { reachable: true })],
base: [total(100, { reachable: false })],
threshold: 2,
});
expect(result.gate).toBeUndefined();
expect(result.skip).toContain("predates the reachable-chunk stats");
});
it("errors when the gate bundle is missing on either side", () => {
const result = compareBundles({
current: [{ bundle: "app", kind: "total", gzipBytes: 1 }],
base: [total(100)],
threshold: 2,
});
expect(result.gate).toBeUndefined();
expect(result.error).toContain("embedding-sdk-chunked total");
});
it("reports a bundle/kind present on only one side without erroring", () => {
const { report } = compareBundles({
current: [total(100), { bundle: "app", kind: "total", gzipBytes: 50 }],
base: [total(100)],
threshold: 2,
});
expect(report.some(line => line.includes("present only in current build"))).toBe(true);
});
});

View File

@@ -9,20 +9,48 @@ inputs:
build-static-viz:
required: true
default: false
logs-artifact-suffix:
artifact-suffix:
required: false
description: Suffix to append to the test logs artifact name to avoid conflicts in matrix jobs
description: >-
Discriminator appended to this job's artifact names (test logs, granular-rerun failed
tests). A job whose matrix runs several concurrent legs MUST set a per-leg-unique value,
or the legs collide on one artifact — for the rerun artifact that means a leg restores
another leg's failed set. Must be stable across attempts of the same job.
edition:
required: false
default: "ee"
description: Edition to test (oss or ee)
quarantine-engine:
runner-index:
required: false
driver-status:
required: false
default: ""
description: >-
Configured status for this driver ("info", "skip", "required", or empty), as reported by `mage driver-decisions`.
The index of this runner among those sharing a partitioned suite. See
`be-tests-bigquery-cloud-sdk-ee` in `.github/workflows/drivers.yml` for an example.
runner-count:
required: false
description: >-
How many runners share the suite. Recommended to set to `strategy.job-total` rather
than a literal so that it doesn't need updating if the `runner-index` matrix changes.
jvms-per-runner:
required: false
default: "1"
description: >-
Concurrent JVMs per runner. Recommended to raise above 1 for drivers whose tests
spend a lot of time waiting on a remote warehouse.
heap-per-jvm:
required: false
default: "4g"
description: >-
`-Xmx` per JVM, applied only when `jvms-per-runner` is more than 1 (otherwise the
the `:ci` alias's `-Xmx12g` is used). To size it: reserve ~1G for the OS and ~2G
of non-heap per JVM, then divide what's left. On a 16G runner that's ~4G each for
two JVMs.
granular-rerun:
required: false
default: "true"
description: >-
When "true" (default), a re-run of a failed job runs only the tests that failed on the
previous attempt (hawk `:only`). Stress workflows set this to "false": they intentionally
run a fixed test set `:times N`, and narrowing to failures would drop that repetition.
outputs:
test-outcome:
@@ -48,16 +76,96 @@ runs:
env:
MB_EDITION: ${{ inputs.edition }}
# Granular rerun: on a re-run of a failed job, restore the list
# of tests that failed on the previous attempt (uploaded below) so the test
# step can target only those instead of the whole suite. Best-effort: a
# missing/empty artifact leaves no file and the run falls back to the full
# suite. First attempts skip this entirely.
#
# Scoped to the IMMEDIATELY preceding attempt: an attempt that passed, or
# that failed with nothing attributable to a var, uploads no artifact, and a
# narrow set must not outlive it. Reading `attempt - 1` means "only what the
# last attempt actually recorded" — anything else falls back to the full suite.
# Workflow expressions have no arithmetic, so the shell computes it.
#
# run-tests.yml calls app-db.yml, backend.yml, drivers.yml and semantic-search.yml
# as part of ONE run, so all four share an artifact namespace. `junit-name` alone is not
# unique there — app-db.yml and drivers.yml both derive `be-tests-postgres-ee` — so
# `artifact-suffix` carries the rest of the discriminator.
- name: Resolve rerun artifact names
if: ${{ inputs.granular-rerun == 'true' }}
shell: bash
env:
RERUN_ARTIFACT_BASE: ${{ format('failed-tests-{0}{1}', inputs.junit-name, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
run: |
echo "RERUN_ARTIFACT=$RERUN_ARTIFACT_BASE-attempt-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_ENV"
echo "RERUN_PREV_ARTIFACT=$RERUN_ARTIFACT_BASE-attempt-$((GITHUB_RUN_ATTEMPT - 1))" >> "$GITHUB_ENV"
# `pattern` (not `name`) because the artifact is legitimately absent whenever the
# previous attempt recorded nothing: `name` treats that as an error, `pattern`
# downloads nothing and moves on. The value is a literal — no glob metacharacters —
# so it matches exactly one artifact, which lands at `target/failed-tests`. A pattern
# that ever matched two would nest them under their artifact names instead, leaving
# the file absent and falling back to the full suite.
- name: Restore previously-failed tests
if: ${{ inputs.granular-rerun == 'true' && github.run_attempt != '1' }}
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: ${{ env.RERUN_PREV_ARTIFACT }}
path: target
- name: Test database driver
run: clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
# On a rerun with a non-empty failed-tests file, target only those vars via
# hawk `:only` (a var-level selector bypasses `:only-tags`, which is fine —
# every listed var already passed that filter on the first attempt).
# Otherwise run exactly as before. Quote the file so the whole `[ns/var ...]`
# vector reaches clojure -X as a single argument.
run: |
ALIASES="dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
BASE="clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
# `:profile-tests` overrides `:test`'s exec-fn, so it has to come last. See dev.profile-tests.
# The `profiled` output is what the collection steps below key off, so that the decision is
# recorded by the step that acts on it rather than re-derived from the environment.
if [ "$PROFILE_TESTS" = "true" ]; then
echo "Profiling this run; a flamegraph will be attached to the job."
echo "profiled=true" >> "$GITHUB_OUTPUT"
BASE="$BASE:profile-tests"
fi
# An explicit selection wins over the rerun narrowing below: it was asked for by hand, on this
# attempt. Names may be separated by commas or spaces; hawk wants them as one vector argument.
if [ -n "$ONLY_TESTS" ]; then
ONLY="[$(echo "$ONLY_TESTS" | tr ',' ' ')]"
echo "Running only: $ONLY"
$BASE :only "$ONLY"
elif [ "${{ inputs.granular-rerun }}" = "true" ] && [ "${{ github.run_attempt }}" != "1" ] && [ -s target/failed-tests ]; then
echo "Granular rerun of previously-failed tests: $(cat target/failed-tests)"
clojure -X:"$ALIASES" :only "$(cat target/failed-tests)"
elif [ -n "$TEST_RUNNER_INDEX" ]; then
CLOJURE_ALIASES="$ALIASES" ./.github/scripts/run-backend-test-jvms.sh ${{ inputs.test-args }}
else
clojure -X:"$ALIASES" ${{ inputs.test-args }}
fi
shell: bash
id: run-test
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
# Driver "info" status can affect this logic. See below.
env:
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}
TEST_HEAP_PER_JVM: ${{ inputs.heap-per-jvm }}
# Keep this step continue-on-error because the quarantine gate below controls whether it
# ultimately fails.
continue-on-error: true
- name: Collect profile
if: ${{ !cancelled() }}
uses: ./.github/actions/collect-profile
with:
profiled: ${{ steps.run-test.outputs.profiled }}
artifact-name: ${{ format('profile-{0}{1}', inputs.junit-name, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
- name: Report failed tests to ci-conductor
if: ${{ !cancelled() && steps.run-test.outcome == 'failure' }}
if: ${{ !cancelled() && ( steps.run-test.outcome == 'failure' || github.run_attempt != '1' ) }}
# This step should NEVER fail the job!
continue-on-error: true
uses: ./.github/actions/report-failures-to-ci-conductor
@@ -65,6 +173,30 @@ runs:
test-suite: ${{ inputs.junit-name }}
adapter: backend
# Granular rerun: record which tests failed so a re-run of this
# job targets only those (restored above). Reuses the backend JUnit adapter
# from the ci-conductor sources the report step just prepared. Best-effort:
# writes nothing unless every failure is attributable to a var, so an
# unattributable failure falls back to a full rerun.
- name: Collect failed tests for rerun
if: ${{ inputs.granular-rerun == 'true' && !cancelled() && steps.run-test.outcome == 'failure' }}
continue-on-error: true
shell: bash
# Clear the restored set first, so a collector that dies before deciding
# can't leave the previous attempt's file to be uploaded as this one's.
run: |
rm -f target/failed-tests
bun release/ci-conductor/src/collect-failed-backend.ts
- name: Upload failed tests for rerun
if: ${{ inputs.granular-rerun == 'true' && !cancelled() && steps.run-test.outcome == 'failure' }}
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ${{ env.RERUN_ARTIFACT }}
path: target/failed-tests
if-no-files-found: ignore
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
# As per the documentation: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
@@ -83,8 +215,10 @@ runs:
if: always() && steps.run-test.outcome != 'success'
uses: actions/upload-artifact@v7
with:
name: ${{ format('test-logs-{0}{1}', github.job, inputs.logs-artifact-suffix && format('-{0}', inputs.logs-artifact-suffix) || '') }}
path: logs/test-log.json
name: ${{ format('test-logs-{0}{1}', github.job, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
# Glob: a single-JVM run writes `logs/test-log.json`; a multi-JVM run merges one
# `logs/test-log-p<index>.json` per JVM.
path: logs/test-log*.json
retention-days: 1
# Failing to upload the artifact should never affect the outcome of the whole test run.
continue-on-error: true
@@ -92,16 +226,9 @@ runs:
# Last, because this is the only step allowed to fail the job: everything above it reports the
# failure (JUnit report, logs, ci-conductor) and must run regardless of the verdict below.
- name: Quarantine gate
# Off master and release branches an "info" driver is data-collection only - the test always passes!
# On master and release branches "info" carries no weight - the quarantine logic applies.
if: |
!cancelled()
&& steps.run-test.outcome == 'failure'
&& !(inputs.driver-status == 'info'
&& !(github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')))
continue-on-error: ${{ inputs.quarantine-engine != 'ci-conductor' }}
if: ${{ !cancelled() && steps.run-test.outcome == 'failure' }}
uses: ./.github/actions/quarantine-gate
with:
adapter: backend
test-suite: ${{ inputs.junit-name }}
enforce-quarantine: ${{ inputs.quarantine-engine == 'ci-conductor' }}
enforce-quarantine: "true"

View File

@@ -1,41 +0,0 @@
name: Update cache
description: |
Update a GitHub Actions cache based on a provided path and key.
If a cache entry already exists for the given key, it is first removed.
This ensures immutability of cache entries, and prevents runtime errors.
inputs:
path:
description: "The path(s) to cache, separated by newlines."
required: true
key:
description: "The cache key to use."
required: true
runs:
using: "composite"
steps:
- name: Check if the cache entry exists
id: cache-lookup
uses: actions/cache/restore@v5
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}
lookup-only: true # Important bit: the action will not fetch anything, just check for the cache existence.
- name: Delete the cache entry if it exists
if: ${{ steps.cache-lookup.outputs.cache-hit == 'true' }}
run: |
echo "Cache entry found for key '${{ inputs.key }}'. Deleting it to ensure immutability."
gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/repos/${{ github.repository }}/actions/caches?key=${{ inputs.key }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
- name: Save the new cache entry
uses: actions/cache/save@v5
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}

View File

@@ -1,4 +1,5 @@
name: Create Backend Dep Bump PRs
description: Opens a pull request for each backend dependency that has a newer version available.
inputs:
github-token:

View File

@@ -1,185 +0,0 @@
name: Upload Test Results to s3
description: Uploads test results files to s3
inputs:
input-path:
required: true
output-name:
required: true
default: test
role-to-assume:
required: true
aws-region:
required: true
default: us-east-1
bucket:
required: true
trunk-api-token:
required: true
variant:
required: false
default: ""
previous-step-outcome:
required: true
driver-status:
required: false
default: ""
description: >-
Configured status for this driver ("info", "skip", "required", or empty). When
"info", the job is reported as passing regardless of the real test outcome, and
that outcome is surfaced via a job summary and (on PRs) a comment instead.
report-variant:
required: false
default: ""
description: >-
Optional label disambiguating matrix legs of the same job, so the info-driver
job summary and PR comment stay distinct per leg.
run-trunk:
required: false
default: "true"
description: Whether to run the Trunk step at all.
runs:
using: "composite"
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885
with:
role-to-assume: ${{ inputs.role-to-assume }}
aws-region: ${{ inputs.aws-region }}
- name: zip test results
env:
INPUT_DIR: ${{ inputs.input-path }}
OUTPUT_FILE: ${{ inputs.output-name }}
shell: bash
run: | # sh
zip ${OUTPUT_FILE}.zip $INPUT_DIR/*.xml
- name: Upload test results to S3
env:
BUCKET: ${{ inputs.bucket }}
OUTPUT_FILE: ${{ inputs.output-name }}
shell: bash
run: | # sh
DATE=$(date '+%Y-%m-%d')
aws s3 cp ${OUTPUT_FILE}.zip s3://$BUCKET/$DATE/$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT/
- name: Upload results to Trunk
if: ${{ always() && github.repository_owner == 'metabase' && inputs.run-trunk == 'true' }}
# Important!
# For "info" drivers off master/release branches, our own status takes precedence: we let this
# step pass so the job is reported as passing regardless of the real outcome (still uploaded).
# On master and release branches, info status is ignored and the real outcome gates the job.
continue-on-error: ${{ inputs.driver-status == 'info' && !(github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')) }}
uses: trunk-io/analytics-uploader@1198efcc0976501147b10a7e343c3fa069527526 # v2.0.9
env:
TRUNK_REPO_URL: git@github.com:metabase/metabase.git
with:
junit-paths: ${{ inputs.input-path }}
variant: ${{ inputs.variant || inputs.output-name }}
org-slug: metabase
dry-run: ${{ (github.event_name == 'pull_request' && github.event.pull_request.draft == true) || github.repository != 'metabase/metabase' }} # quarantine, but do not upload results
token: ${{ inputs.trunk-api-token }}
use-cache: true
previous-step-outcome: ${{ inputs.previous-step-outcome }}
# For "info" drivers the job is forced green above, so surface the real outcome here —
# but only when it did NOT pass, to avoid spamming summaries/comments for healthy runs.
# Never include logs or stack traces — only the real status and a link to the run.
- name: Report info-level driver result (job summary)
if: ${{ always() && inputs.driver-status == 'info' && inputs.previous-step-outcome == 'failure' }}
shell: bash
env:
OUTCOME: ${{ inputs.previous-step-outcome }}
LABEL: ${{ inputs.output-name }}
VARIANT: ${{ inputs.report-variant }}
run: | # sh
if [ "$OUTCOME" = "success" ]; then EMOJI="✅"; else EMOJI="❌"; fi
TITLE="$LABEL"
[ -n "$VARIANT" ] && TITLE="$LABEL ($VARIANT)"
RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
{
echo "### ℹ️ Info-level driver: $TITLE"
echo ""
echo "Reported as passing (non-blocking). Real result: $EMOJI \`$OUTCOME\`"
echo ""
echo "[View run]($RUN_URL)"
} >> "$GITHUB_STEP_SUMMARY"
# Maintain ONE PR comment per commit for all info drivers. Each leg upserts its own line
# (keyed by a hidden tag); a passing leg removes its line, and the comment is deleted once
# no failing legs remain. Comments from older commits are cleaned up. Runs on pass and fail
# so recoveries self-heal. Never includes logs or stack traces — only status and a run link.
- name: Sync info-level driver result into the PR comment
if: ${{ always() && inputs.driver-status == 'info' && github.event_name == 'pull_request' }}
uses: actions/github-script@v9
continue-on-error: true
env:
OUTCOME: ${{ inputs.previous-step-outcome }}
LABEL: ${{ inputs.output-name }}
VARIANT: ${{ inputs.report-variant }}
with:
script: | # js
const { OUTCOME, LABEL, VARIANT, GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env;
const pr = context.payload.pull_request;
if (!pr) return;
const { owner, repo } = context.repo;
const sha = pr.head.sha;
// PREFIX matches the current per-commit marker AND the older per-leg one, so legacy
// comments from a previous version get cleaned up on the next sync.
const PREFIX = "<!-- info-driver-result";
const marker = `<!-- info-driver-results:${sha} -->`;
const header = `${marker}\nℹ️ **Info-level drivers** failed (non-blocking) on \`${sha.slice(0, 7)}\` — these do not gate:`;
const lineKey = `${LABEL}:${VARIANT}`;
const title = VARIANT ? `${LABEL} (${VARIANT})` : LABEL;
const runUrl = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`;
// Only real failures are reported; cancelled/skipped/success remove this leg's line.
// List-item line with the hidden key tag at the END so markdown (links) renders.
const ourLine = OUTCOME === "failure"
? `- **${title}** — ❌ \`${OUTCOME}\` ([Logs](${runUrl})) <!--l:${lineKey}-->`
: null;
const linesOf = (body) => body.split("\n").filter((l) => l.includes("<!--l:"));
const sync = async () => {
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: pr.number, per_page: 100,
});
const tagged = comments.filter((c) => c.body.includes(PREFIX));
// Drop comments left over from older commits.
for (const c of tagged.filter((c) => !c.body.includes(marker))) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }).catch(() => {});
}
const mine = tagged
.filter((c) => c.body.includes(marker))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// Merge any duplicates raced into existence; latest write of each line wins.
const byKey = new Map();
for (const c of mine) {
for (const l of linesOf(c.body)) {
const m = l.match(/<!--l:(.*?)-->/);
if (m) byKey.set(m[1], l);
}
}
if (ourLine) byKey.set(lineKey, ourLine);
else byKey.delete(lineKey);
const lines = [...byKey.values()].sort();
const target = mine[0];
for (const c of mine.slice(1)) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }).catch(() => {});
}
if (lines.length === 0) {
if (target) await github.rest.issues.deleteComment({ owner, repo, comment_id: target.id }).catch(() => {});
return;
}
const body = [header, "", ...lines].join("\n");
if (target) {
await github.rest.issues.updateComment({ owner, repo, comment_id: target.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
};
// Retry to shrink the window where parallel legs clobber each other's update.
for (let i = 0; i < 4; i++) {
try { await sync(); break; }
catch (e) { if (i === 3) throw e; await new Promise((r) => setTimeout(r, 400 * (i + 1))); }
}

View File

@@ -1,22 +0,0 @@
name: 'Verify Organization Membership'
description: 'Check if a user is a member of a GitHub organization'
inputs:
github_token:
description: 'Token with access to the organization'
required: true
organization_name:
description: 'Organization name'
required: true
username:
description: 'Username'
required: true
outputs:
is_member:
value: ${{ steps.verify-membership.outputs.is_member }}
description: "Is the user a member of the organization"
runs:
using: "composite"
steps:
- run: bash ${{ github.action_path }}/verify_membership.sh ${{ inputs.github_token }} ${{ inputs.organization_name }} ${{ inputs.username }}
id: verify-membership
shell: bash

View File

@@ -1,25 +0,0 @@
#!/bin/bash
TOKEN=$1
ORG_NAME=$2
USERNAME=$3
response=$(curl -L -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG_NAME/members/$USERNAME")
# Github returns an empty response with 204 status if user is a member and 404 status otherwise
# See: https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#check-organization-membership-for-a-user
if [ "$response" == "204" ]; then
echo "The user $USERNAME is a member of $ORG_NAME"
echo "is_member=true" >> $GITHUB_OUTPUT
elif [ "$response" == "404" ]; then
echo "The user $USERNAME is not a member of $ORG_NAME"
echo "is_member=false" >> $GITHUB_OUTPUT
else
echo "Failed to determine the membership status for $USERNAME in $ORG_NAME"
echo "is_member=false" >> $GITHUB_OUTPUT
exit 1
fi

View File

@@ -7,9 +7,8 @@ default: &default
- "locales/**"
- "bin/**/!(*.md)"
actionlint:
- ".github/workflows/*.yml"
- ".github/workflows/*.yaml"
workflows:
- added|modified: ".github/workflows/*.{yml,yaml}"
shared_sources: &shared_sources
- "src/**/*.cljc"
@@ -54,11 +53,6 @@ frontend_sources: &frontend_sources
- "enterprise/frontend/!(test)**/**/{!(*.spec),}.{js,jsx,ts,tsx,css}"
- "frontend/!(test)**/**/{!(*.spec),}.{js,jsx,ts,tsx,css}"
# Used only in .github/workflows/conventional-commit-pr-title-reminder.yml
frontend_embedding_sources:
- "enterprise/frontend/src/{embedding,embedding-*}/**/{!(*.spec),}.{js,jsx,ts,tsx,css}"
- "enterprise/frontend/src/metabase-enterprise/{embedding,embedding-*}**/{!(*.spec),}.{js,jsx,ts,tsx,css}"
# Used only in .github/workflows/embedding-sdk-labels-reminder.yml
frontend_embedding_sdk_package_sources:
- "enterprise/frontend/src/embedding-sdk-package/**/!(*.spec).{js,jsx,ts,tsx,css}"
@@ -69,9 +63,10 @@ frontend_specs: &frontend_specs
- "frontend/**/tests/**"
- "frontend/**/*.unit.*"
- "enterprise/frontend/**/*.unit.*"
- ".github/**/*.unit.spec.*"
- "jest.config.js"
- "jest.tz.unit.conf.json"
- "jest.base.conf.js"
- "jest.esm-packages.js"
- "jest.tz.unit.conf.js"
release_source: &release_sources
- "release/**"
@@ -83,6 +78,7 @@ frontend_all: &frontend_all
frontend_loki_ci: &frontend_loki_ci
- ".github/workflows/loki.yml"
- "loki.config.js"
- ".loki/**"
- ".storybook/**"
@@ -90,14 +86,52 @@ frontend_unit_infra: &frontend_unit_infra
- *frontend_configs
- *frontend_ci
- "jest.config.js"
- "jest.tz.unit.conf.json"
- "jest.base.conf.js"
- "jest.esm-packages.js"
- "jest.tz.unit.conf.js"
- "frontend/test/**"
- "frontend/**/__mocks__/**"
- "frontend/src/embedding-sdk-shared/jest/**"
frontend_loki_infra: &frontend_loki_infra
- *frontend_configs
- *shared_ci
- *frontend_loki_ci
- "frontend/build/shared/**"
- "frontend/test/__support__/**"
- "patches/**"
# dependency-cruiser doesn't follow CSS @import, so global styles must run every story.
- "frontend/src/metabase/css/**/*.css"
# Storybook serves resources/frontend_client as static files, and the stories bundle the app's SVGs.
- "resources/frontend_client/**"
- "frontend/**/*.svg"
- "enterprise/frontend/**/*.svg"
frontend_loki_all:
- *frontend_sources
- *frontend_loki_infra
# Everything the ci-scripts jest project runs or depends on.
# test-unit ignores that project, so this filter is the only thing that reruns it.
ci_scripts:
- *frontend_ci
- ".nvmrc"
# file-paths.unit.spec.ts asserts what this file's own patterns match, so a change to it has to
# run the specs that guard it.
- ".github/file-paths.yaml"
- ".github/scripts/**"
- ".github/actions/**"
- ".github/workflows/loki.yml"
- ".storybook/story-files.cjs"
- "frontend/lint/module-boundaries.mjs"
- "frontend/lint/shared-tiers.mjs"
- "frontend/test/jest-test-paths-filter.ts"
- ".dependency-cruiser.cjs"
- "jest.config.js"
- "jest.base.conf.js"
- "jest.esm-packages.js"
- "package.json"
- "bun.lock"
# e2e harness/support code (commands, support file, runner). Not in the
# instrumented bundle, so it maps to no covered module — a change here forces a
@@ -111,13 +145,6 @@ backend_ci: &backend_ci
- ".github/workflows/backend.yml"
- ".github/workflows/drivers.yml"
backend_presto_kerberos:
- "**/presto_jdbc/**"
- "**/presto-jdbc/**" # the driver module has a hyphen. underscores are classpaths
- "**/presto_jdbc.clj"
- "**/presto_jdbc_test.clj"
- ".github/workflows/presto-kerberos-integration-test.yml"
backend_sources: &backend_sources
- *shared_sources
- "**deps.edn"
@@ -125,11 +152,15 @@ backend_sources: &backend_sources
- "src/**"
- "modules/drivers/{*,*/{*,!(test)/**}}"
- "resources/**"
- ".clj-kondo/**"
# Ratchet files drive project_ratchet_checks alone, so automation PRs that lower budgets
# run the Project tests job by themselves. Patterns are OR-ed, so a separate "!..." entry would match
# every other path instead of excluding this one.
- ".clj-kondo/**/!(ratchets.edn)"
backend_specs: &backend_specs
- *shared_specs
- "test/**"
- "dev/test/**"
- "enterprise/backend/test/**"
- "test_modules/**"
- "modules/drivers/*/test/**"
@@ -142,6 +173,48 @@ backend_all: &backend_all
- *backend_sources
- *backend_specs
# Project checks have narrower triggers than the backend matrix.
# Changes to their shared CI entry points exercise every suite; otherwise each
# suite follows only the files that can affect its result.
project_tests_infra: &project_tests_infra
- ".github/actions/prepare-backend/**"
- ".github/file-paths.yaml"
- ".github/workflows/backend.yml"
- ".github/workflows/run-tests.yml"
- "bb.edn"
- "bin/mage"
- "bin/mage.bb"
- "mage/src/mage/color.clj"
- "mage/src/mage/project_tests.clj"
- "mage/src/mage/shell.clj"
- "mage/src/mage/util.clj"
project_backend_checks:
- *project_tests_infra
- "{src,test,enterprise,dev,bin,mage}/**/*.{clj,cljc,cljs}"
- "modules/**/*.clj"
- "modules/**/*.cljc"
- "modules/**/*.cljs"
- "**deps.edn"
- ".clj-kondo/{src,test}/**/*.{clj,cljc,cljs}"
- "{resources,test_resources,test_config}/**/*.{clj,cljc,cljs}"
- ".clj-kondo/config.edn"
- ".clj-kondo/config/modules/config.edn"
- ".github/team.json"
- "dev/resources/dev/modules_config_test/**"
# Only the ratchet check reads the budgets, so a diff that touches nothing else runs that suite alone.
project_ratchet_checks:
- ".clj-kondo/ratchets.edn"
- ".clj-kondo/config/modules/ratchets.edn"
project_migration_checks:
- *project_tests_infra
- "bin/lint-migrations-file/**"
- "resources/migrations/**/*.{yaml,yml}"
- "src/metabase/app_db/custom_migrations.clj"
- "src/metabase/app_db/custom_migrations/**"
sources: &sources
- *shared_sources
- *frontend_sources
@@ -155,7 +228,7 @@ custom_viz_all:
e2e_ci: &e2e_ci
- *shared_ci
- ".github/actions/prepare-uberjar-artifact/**"
- ".github/workflows/uberjar.yml"
- ".github/actions/fetch-artifact/action.yml"
- ".github/actions/e2e-prepare-containers/**"
- ".github/actions/prepare-cypress/**"
@@ -166,7 +239,7 @@ e2e_specs: &e2e_specs
embedding_sdk_ci: &embedding_sdk_ci
- *shared_ci
- ".github/actions/prepare-uberjar-artifact/**"
- ".github/workflows/uberjar.yml"
- ".github/actions/fetch-artifact/action.yml"
- ".github/actions/prepare-cypress/**"
- ".github/workflows/embedding-sdk*.yml"
@@ -188,7 +261,7 @@ embedding_sdk_host_sample_apps:
- "e2e/test-host-app/**/*.cy.*.{js,ts}"
snowplow: &snowplow
- ".github/workflows/snowplow.yml"
- ".github/workflows/lint.yml"
- "snowplow/**"
- "bin/lint-snowplow-schemas.bb"
- "bin/lint-snowplow-schemas-test.bb"
@@ -217,7 +290,7 @@ yaml:
- "**/*.yaml"
codeql:
- ".github/workflows/codeql.yml"
- ".github/workflows/lint.yml"
- "frontend/src/**"
- "enterprise/frontend/src/**"
@@ -225,26 +298,50 @@ i18n:
- *default
- ".github/actions/prepare-frontend/**"
- ".github/actions/prepare-backend/**"
- ".github/workflows/i18n.yml"
- ".github/workflows/lint.yml"
- *sources
visualizations:
- "frontend/src/metabase/visualizations/**"
- "frontend/src/metabase/static-viz/**"
- "src/metabase/pulse/**"
cljs:
- *shared_sources
- *shared_specs
- ".github/workflows/cljs.yml"
- "shadow-cljs.edn"
build_scripts:
- ".github/actions/prepare-backend/**"
- ".github/file-paths.yaml"
- ".github/workflows/build-scripts.yml"
- "bin/**"
- "deps.edn"
embedder_plugin:
- ".github/actions/prepare-backend/**"
- ".github/file-paths.yaml"
- ".github/workflows/build-scripts.yml"
- "bin/build-embedder-plugin.sh"
- "bin/build/src/build/embedder_model.clj"
- "bin/build/src/build/plugin_uberjar.clj"
- "bin/build/src/build_embedder_plugin.clj"
- "deps.edn"
- "modules/embedder/**"
- "enterprise/backend/src/metabase_enterprise/data_complexity_score/complexity_embedders.clj"
- "enterprise/backend/src/metabase_enterprise/data_complexity_score/settings.clj"
- "enterprise/backend/src/metabase_enterprise/data_complexity_score/synonym_source.clj"
- "enterprise/backend/src/metabase_enterprise/semantic_search/embedding.clj"
- "enterprise/backend/src/metabase_enterprise/semantic_search/settings.clj"
- "enterprise/backend/test/metabase_enterprise/semantic_search/appdb_pgvector_mode_test.clj"
- "enterprise/backend/test/metabase_enterprise/semantic_search/embedding_test.clj"
- "src/metabase/embeddings/provider.clj"
- "src/metabase/embeddings/startup.clj"
- "src/metabase/plugins/**"
- "test/metabase/embeddings/embedder_plugin_artifact_test.clj"
serialization_baseline:
- ".github/workflows/serialization.yml"
- "test_resources/serialization_baseline/**"
openapi:
- ".github/workflows/openapi.yml"
- ".github/workflows/lint.yml"
- "src/metabase/api/**"
- "src/metabase/api_routes/**"
- "src/metabase/*_rest/api.clj"

View File

@@ -0,0 +1,12 @@
---
applyTo: "e2e/**/*.cy.spec.{js,ts}"
---
# Cypress E2E Spec Review
When reviewing Cypress E2E specs, apply the guidelines in these files:
- `.claude/skills/_shared/cypress-conventions.md`
- `.claude/skills/e2e-test-review/SKILL.md`
These are in addition to the TypeScript/JavaScript instructions, which also match these files.

View File

@@ -1,7 +1,12 @@
import micromatch from "micromatch";
export type ModuleDef = { type: string; pattern: string };
export type Rule = { from: string[]; allow: string[] };
export type Rule = {
from: string[];
allow?: string[];
disallow?: string[];
};
export type ModuleNode = { type: string; regex: RegExp };
@@ -100,6 +105,28 @@ export function getAffectedFiles(
return affected;
}
/** The file plus every file it transitively imports. */
export function getTransitiveDependencies(
fileDependencies: FileDependency[],
file: string,
): Set<string> {
const dependenciesOf = new Map(
fileDependencies.map(({ source, dependencies }) => [source, dependencies]),
);
const reached = new Set([file]);
const queue = [file];
while (queue.length > 0) {
const current = queue.shift()!;
for (const dependency of dependenciesOf.get(current) ?? []) {
if (!reached.has(dependency)) {
reached.add(dependency);
queue.push(dependency);
}
}
}
return reached;
}
export type ModuleGraph = {
nodes: ModuleNode[];
/**
@@ -203,8 +230,11 @@ export function buildModuleGraph(
allTypes.map((type) => [type, new Set()]),
);
for (const rule of rules) {
// Grandfathered imports that break the disallow rules still exist in the code,
// and selection must cover every import that actually exists.
// So disallow rules are ignored, at the cost of a looser graph.
const fromTypes = rule.from.flatMap(expandPattern);
const allowTypes = rule.allow.flatMap(expandPattern);
const allowTypes = (rule.allow ?? []).flatMap(expandPattern);
for (const target of allowTypes) {
for (const importer of fromTypes) {
if (importer !== target) {

View File

@@ -6,6 +6,7 @@ import {
getAffectedFiles,
getAffectedModules,
getChangedModules,
getTransitiveDependencies,
mapFileToModule,
parseCruiseModules,
} from "./affected-modules";
@@ -123,6 +124,31 @@ describe("affected modules", () => {
});
});
describe("getTransitiveDependencies", () => {
it("should return the file and everything it transitively imports", () => {
expect(
[...getTransitiveDependencies(FILE_DEPS, "src/app.js")].sort(),
).toEqual(["src/app.js", "src/foo/foo.tsx", "src/utils/colors.ts"]);
});
it("should return only the file when it imports nothing", () => {
expect([
...getTransitiveDependencies(FILE_DEPS, "src/utils/colors.ts"),
]).toEqual(["src/utils/colors.ts"]);
});
it("should stop at an import cycle", () => {
const cyclic = [
{ source: "src/a.ts", dependencies: ["src/b.ts"] },
{ source: "src/b.ts", dependencies: ["src/a.ts"] },
];
expect([...getTransitiveDependencies(cyclic, "src/a.ts")].sort()).toEqual(
["src/a.ts", "src/b.ts"],
);
});
});
describe("buildUsageModuleGraph + getAffectedModules", () => {
const usageGraph = buildUsageModuleGraph(ELEMENTS, FILE_DEPS);

View File

@@ -11,6 +11,7 @@ import {
buildUsageModuleGraph,
getAffectedModules,
getChangedModules,
getTransitiveDependencies,
mapFileToModule,
} from "./affected-modules";
@@ -21,6 +22,7 @@ export type TestPlanStats = {
be_files_total: number;
unit_infra_touched: boolean;
loki_infra_touched: boolean;
loki_preview_touched: boolean;
shared_sources_touched: boolean;
fe_modules_total: number;
fe_modules_changed: number;
@@ -48,9 +50,12 @@ export type CreateTestPlanInput = {
elements: ModuleDef[];
rules: Rule[];
changedFiles: string[];
// Parsed dependency-cruiser edges, or null to fall back to the rules graph.
fileDependencies: FileDependency[] | null;
// Loads the dependency-cruiser edges, called only when some suite can be narrowed.
// Null falls back to the rules graph.
loadFileDependencies: () => FileDependency[] | null;
testFilesBySuite: { unit: string[]; loki: string[]; e2e: string[] };
// Storybook's preview file, whose imports wrap every story.
lokiPreviewFile: string;
e2eSpecFiles: Record<string, string[]> | null;
unitInfraTouched: boolean;
lokiInfraTouched: boolean;
@@ -63,6 +68,7 @@ export type CreateTestPlanInput = {
};
// Tests whose owning module is affected.
// A spec outside the module tree always runs, because the graph cannot scope it.
export function filterAffectedTests(
nodes: ModuleNode[],
affected: Set<string>,
@@ -70,7 +76,7 @@ export function filterAffectedTests(
): string[] {
return testFiles.filter((file) => {
const module = mapFileToModule(nodes, file);
return module !== null && affected.has(module);
return module === null || affected.has(module);
});
}
@@ -130,8 +136,9 @@ export function createTestPlan({
elements,
rules,
changedFiles,
fileDependencies,
loadFileDependencies,
testFilesBySuite,
lokiPreviewFile,
e2eSpecFiles,
unitInfraTouched,
lokiInfraTouched,
@@ -143,16 +150,11 @@ export function createTestPlan({
beFilesTotal,
}: CreateTestPlanInput): TestPlan {
const rulesGraph = buildModuleGraph(elements, rules);
const usageGraph = fileDependencies
? buildUsageModuleGraph(elements, fileDependencies)
: rulesGraph;
const nodes = rulesGraph.nodes;
// Distinct module types (an element type can span several patterns).
const totalModules = new Set(elements.map((el) => el.type)).size;
const changedModules = getChangedModules(nodes, changedFiles);
const rulesAffected = getAffectedModules(rulesGraph, changedFiles);
const usageAffected = getAffectedModules(usageGraph, changedFiles);
// The coarse "feature" tier is the only set the e2e manifest is ever
// collapsed to (see filterAffectedE2eSpecs).
@@ -161,7 +163,7 @@ export function createTestPlan({
// cljc/cljs compile into the FE bundle, so they force a full run that module
// selection can't narrow — same effect as a suite's own infra changing.
const unitForceAll = unitInfraTouched || sharedSourcesTouched;
const lokiForceAll = lokiInfraTouched || sharedSourcesTouched;
const lokiInfraForceAll = lokiInfraTouched || sharedSourcesTouched;
// e2e is integration-level, so anything the FE-coverage manifest can't see
// forces a full run: cljc/cljs in the bundle (sharedSourcesTouched), a backend
// change that can break the UI (beFilesChanged), an e2e harness/support change
@@ -172,6 +174,28 @@ export function createTestPlan({
beFilesChanged > 0 ||
e2eSpecFiles === null;
// The cruise takes seconds, so it is skipped when every suite runs in full anyway.
const edges =
unitForceAll && lokiInfraForceAll && e2eForceAll
? null
: loadFileDependencies();
const usageGraph = edges
? buildUsageModuleGraph(elements, edges)
: rulesGraph;
const usageAffected = getAffectedModules(usageGraph, changedFiles);
// The preview wraps every story, so a change to a file it imports can change any screenshot.
const previewDependencies = edges
? getTransitiveDependencies(edges, lokiPreviewFile)
: new Set<string>();
const lokiPreviewTouched = changedFiles.some((file) =>
previewDependencies.has(file),
);
// Without the cruise, the rules graph cannot account for the preview's
// global dependencies, so it cannot safely narrow Loki.
const lokiForceAll =
lokiInfraForceAll || lokiPreviewTouched || edges === null;
const select = (forceAll: boolean, affected: Set<string>, files: string[]) =>
forceAll ? files : filterAffectedTests(nodes, affected, files);
@@ -208,6 +232,7 @@ export function createTestPlan({
be_files_total: beFilesTotal,
unit_infra_touched: unitInfraTouched,
loki_infra_touched: lokiInfraTouched,
loki_preview_touched: lokiPreviewTouched,
shared_sources_touched: sharedSourcesTouched,
fe_modules_total: totalModules,
fe_modules_changed: changedModules.size,

View File

@@ -26,8 +26,9 @@ const E2E_FILES = ["e2e/test/scenarios/a.cy.spec.ts"];
const baseInput = {
elements: ELEMENTS,
rules: RULES,
fileDependencies: null,
loadFileDependencies: () => null,
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e: E2E_FILES },
lokiPreviewFile: ".storybook/preview.tsx",
e2eSpecFiles: null,
unitInfraTouched: false,
lokiInfraTouched: false,
@@ -41,7 +42,10 @@ const baseInput = {
describe("createTestPlan", () => {
it("runs only specs in affected modules (rules graph)", () => {
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
});
expect(plan.stats.fe_modules_changed).toBe(1);
expect(plan.stats.fe_modules_affected_rules).toBe(1);
@@ -50,8 +54,8 @@ describe("createTestPlan", () => {
"src/foo/foo.unit.spec.ts",
]);
expect(plan.stats.unit_specs_to_run_rules).toBe(2);
expect(plan.stats.loki_stories_to_run_rules).toBe(1);
// With no file deps, usage falls back to rules.
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
// With no file deps, unit usage falls back to rules, but Loki runs in full.
expect(plan.stats.unit_specs_to_run_usage).toBe(2);
});
@@ -60,7 +64,7 @@ describe("createTestPlan", () => {
...baseInput,
changedFiles: ["src/utils/colors.ts"],
// Only feature/foo actually imports lib/utils.
fileDependencies: [
loadFileDependencies: () => [
{ source: "src/foo/foo.tsx", dependencies: ["src/utils/colors.ts"] },
],
});
@@ -91,14 +95,69 @@ describe("createTestPlan", () => {
...baseInput,
changedFiles: ["docs/readme.md"],
unitInfraTouched: true,
loadFileDependencies: () => [],
});
expect(plan.stats.unit_specs_to_run_rules).toBe(UNIT_FILES.length);
expect(plan.stats.loki_stories_to_run_rules).toBe(0);
});
// The preview imports the theme, which imports the colors.
const PREVIEW_DEPS = [
{ source: ".storybook/preview.tsx", dependencies: ["src/utils/theme.ts"] },
{ source: "src/utils/theme.ts", dependencies: ["src/utils/colors.ts"] },
{ source: "src/foo/foo.tsx", dependencies: ["src/foo/x.ts"] },
];
it("forces a full Loki run when the Storybook preview imports a changed file", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/utils/colors.ts"],
loadFileDependencies: () => PREVIEW_DEPS,
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
});
expect(plan.stats.loki_preview_touched).toBe(true);
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
// Unit and e2e still narrow to what the usage graph reaches from the change.
expect(plan.fe_unit_specs_to_run).toEqual(["src/utils/utils.unit.spec.ts"]);
expect(plan.e2e_specs_to_run).toEqual([]);
});
it("narrows Loki when the Storybook preview does not import the changed file", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
loadFileDependencies: () => PREVIEW_DEPS,
});
expect(plan.stats.loki_preview_touched).toBe(false);
expect(plan.loki_stories_to_run).toEqual(["src/foo/Foo.stories.tsx"]);
});
it("runs all Loki stories when the cruise fails even if rules select fewer", () => {
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
loadFileDependencies: () => null,
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
});
expect(plan.stats.loki_preview_touched).toBe(false);
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
expect(plan.stats.loki_stories_to_run_usage).toBe(LOKI_FILES.length);
// Unit and E2E keep their rules-graph fallback.
expect(plan.fe_unit_specs_to_run).toEqual(UNIT_FILES.slice(0, 2));
expect(plan.e2e_specs_to_run).toEqual([]);
});
it("runs the full e2e suite when no coverage manifest is available", () => {
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
const plan = createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
});
expect(plan.stats.e2e_specs_to_run_rules).toBe(E2E_FILES.length);
expect(plan.stats.e2e_specs_to_run_usage).toBe(E2E_FILES.length);
@@ -229,21 +288,53 @@ describe("createTestPlan", () => {
expect(plan.stats.fe_files_changed).toBe(5);
expect(plan.stats.be_files_changed).toBe(3);
});
it("does not build the usage graph when every suite runs in full", () => {
const loadFileDependencies = jest.fn(() => null);
createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
sharedSourcesTouched: true,
loadFileDependencies,
});
expect(loadFileDependencies).not.toHaveBeenCalled();
});
it("builds the usage graph when any suite can be narrowed", () => {
const loadFileDependencies = jest.fn(() => null);
createTestPlan({
...baseInput,
changedFiles: ["src/foo/x.ts"],
unitInfraTouched: true, // loki can still be narrowed
loadFileDependencies,
});
expect(loadFileDependencies).toHaveBeenCalledTimes(1);
});
});
describe("filterAffectedTests", () => {
it("keeps only tests inside affected modules", () => {
it("keeps tests inside affected modules, drops tests in unaffected ones", () => {
const nodes = buildNodes(ELEMENTS);
const affected = new Set(["feature/foo", "lib/utils"]);
const tests = [
"src/foo/foo.unit.spec.ts",
"src/utils/colors.unit.spec.ts",
"src/bar/bar.unit.spec.ts",
"docs/unrelated.unit.spec.ts",
];
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
"src/foo/foo.unit.spec.ts",
"src/utils/colors.unit.spec.ts",
]);
});
it("keeps a test that maps to no module (unknown scope)", () => {
const nodes = buildNodes(ELEMENTS);
const affected = new Set(["feature/foo"]);
const tests = ["docs/unrelated.unit.spec.ts", "src/bar/bar.unit.spec.ts"];
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
"docs/unrelated.unit.spec.ts",
]);
});
});

View File

@@ -1,152 +0,0 @@
/* eslint-disable import/no-commonjs */
// Prepares the rows the bundle-size stats logger uploads to stats.metabase.com.
//
// For each measured (bundle, kind) it computes the delta — raw, gzipped and
// brotli (as-served) bytes plus percent — against the previously *plotted* data
// point (restored from a rolling cache), so the deltas can be charted directly.
// It also decides whether this commit is worth recording at all: a point is kept
// when the as-served size moved by at least MIN_DELTA_PERCENT, or when a new
// bundle/kind appears. Only master builds run through here — releases form
// non-linear lines whose deltas are meaningless, so they're not recorded (their
// historical sizes are backfilled separately).
//
// buildStatsRows is pure (data in, data out) so it can be unit-tested; main()
// is the thin I/O wrapper that reads env/files and writes the rows, cache and
// step outputs.
const fs = require("fs");
const path = require("path");
const deltaPercent = (current, base) =>
base ? Math.round(((current - base) * 10000) / base) / 100 : "";
/**
* Turn the current measurements + the last plotted point into upload rows, the
* slim cache rows for the next diff, and the keep/skip decision.
*
* The significance threshold tracks the as-served size: brotli where the build
* ships a precompressed .br, otherwise gzip. Current measurements always carry
* brotliBytes (estimated when no .br shipped), but a cached base point recorded
* before brotli logging may not, so the delta still falls back to gzip per row.
*/
function buildStatsRows({ measurements, previous, threshold, date, commit, commitMessage, version }) {
const previousOf = (bundle, kind) => previous.find(row => row.bundle === bundle && row.kind === kind);
let maxServedDeltaPercent = 0;
let hasNewSeries = false;
const rows = measurements.map(measurement => {
const base = previousOf(measurement.bundle, measurement.kind);
const hasBrotli = measurement.brotliBytes != null && base?.brotliBytes != null;
const gzipDeltaPercent = deltaPercent(measurement.gzipBytes, base?.gzipBytes);
const brotliDeltaPercent = hasBrotli ? deltaPercent(measurement.brotliBytes, base.brotliBytes) : "";
if (!base) {
hasNewSeries = true;
} else {
const servedDeltaPercent = hasBrotli ? brotliDeltaPercent : gzipDeltaPercent;
maxServedDeltaPercent = Math.max(maxServedDeltaPercent, Math.abs(servedDeltaPercent));
}
return {
"Date": date,
"Version": version,
"Commit": commit,
"Bundle": measurement.bundle,
"Kind": measurement.kind, // "initial" or "total"
// The stats table carries a free-text Description column; append-csv
// requires every table column to be present. Populate it with the commit
// subject so the chart's points are self-describing.
"Description": commitMessage,
"Raw bytes": measurement.rawBytes,
"Gzip bytes": measurement.gzipBytes,
"Brotli bytes": measurement.brotliBytes, // as-served (shipped .br, else estimated)
"File count": measurement.fileCount,
"Raw bytes delta": base ? measurement.rawBytes - base.rawBytes : "",
"Gzip bytes delta": base ? measurement.gzipBytes - base.gzipBytes : "",
"Brotli bytes delta": hasBrotli ? measurement.brotliBytes - base.brotliBytes : "",
"Raw delta %": deltaPercent(measurement.rawBytes, base?.rawBytes),
"Gzip delta %": gzipDeltaPercent,
"Brotli delta %": brotliDeltaPercent,
};
});
const firstPoint = previous.length === 0;
const significant = firstPoint || hasNewSeries || maxServedDeltaPercent >= threshold;
// Slim rows the next run diffs against. The workflow only persists this to the
// cache when we actually push, so the cached reference always stays the last
// *plotted* point (cumulative drift is caught).
const cacheRows = measurements.map(({ bundle, kind, rawBytes, gzipBytes, brotliBytes }) => ({
bundle,
kind,
rawBytes,
gzipBytes,
brotliBytes,
}));
const reason = firstPoint
? "first point"
: hasNewSeries
? "new bundle/kind series"
: `max served Δ ${maxServedDeltaPercent.toFixed(2)}% (threshold ${threshold}%)`;
return { rows, cacheRows, significant, reason, maxServedDeltaPercent, firstPoint, hasNewSeries };
}
module.exports = { buildStatsRows };
const readJson = filePath => (filePath && fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, "utf8")) : null);
const writeJson = (filePath, value) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value));
};
const setOutput = (env, name, value) => {
if (env.GITHUB_OUTPUT) {
fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
// version.properties (extracted from the uberjar) carries the build's tag.
// Non-release (master) builds report tag=vUNKNOWN, so leave Version empty there.
function readVersion(versionPropsPath) {
const raw =
versionPropsPath && fs.existsSync(versionPropsPath)
? (fs.readFileSync(versionPropsPath, "utf8").match(/^tag=(.*)$/m)?.[1]?.trim() ?? "")
: "";
return raw === "vUNKNOWN" ? "" : raw;
}
function main() {
const env = process.env;
const measurements = readJson(env.CURRENT);
if (!Array.isArray(measurements) || measurements.length === 0) {
console.error(`::error::No bundle measurements found at ${env.CURRENT}`);
process.exit(1);
}
const result = buildStatsRows({
measurements,
// The last plotted point, as slim rows: [{ bundle, kind, rawBytes, gzipBytes, brotliBytes }].
previous: readJson(env.LAST) || [],
threshold: Number(env.MIN_DELTA_PERCENT ?? 1),
date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
commit: (env.HEAD_SHA || "").slice(0, 12),
commitMessage: (env.COMMIT_MESSAGE || "").split("\n")[0], // subject line only
version: readVersion(env.VERSION_PROPS),
});
writeJson(env.ROWS_OUT, result.rows);
writeJson(env.CACHE_OUT, result.cacheRows);
console.log(`${result.significant ? "RECORD" : "SKIP"} — ${result.reason}`);
setOutput(env, "significant", result.significant ? "true" : "false");
setOutput(
env,
"max_delta_percent",
result.firstPoint || result.hasNewSeries ? "" : result.maxServedDeltaPercent.toFixed(2),
);
}
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,227 @@
// Prepares the rows the bundle-size stats logger imports into eng-stats-importer.
//
// For each measured (bundle, kind) it computes the delta — raw, gzipped and
// brotli (as-served) bytes plus percent — against the previously *plotted* data
// point (restored from a rolling cache), so the deltas can be charted directly.
// It also decides whether this commit is worth recording at all: a point is kept
// when the as-served size moved by at least MIN_DELTA_PERCENT, or when a new
// bundle/kind appears. Only master builds run through here — releases form
// non-linear lines whose deltas are meaningless, so they're not recorded (their
// historical sizes are backfilled separately).
//
// buildStatsRows is pure (data in, data out) so it can be unit-tested; main()
// is the thin I/O wrapper that reads env/files and writes the rows, cache and
// step outputs.
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
/** One measurement emitted by measure-bundle-sizes. */
export interface Measurement {
bundle: string;
kind: "initial" | "total";
rawBytes: number;
gzipBytes: number;
brotliBytes: number;
fileCount: number;
}
/**
* The slim row cached between runs, holding the last *plotted* point.
* `brotliBytes` is optional because a point cached before brotli logging
* landed does not carry it — see buildStatsRows' per-row gzip fallback.
*/
export type CacheRow = Pick<Measurement, "bundle" | "kind" | "rawBytes" | "gzipBytes"> & {
brotliBytes?: number;
};
export interface BuildStatsRowsInput {
measurements: Measurement[];
previous: CacheRow[];
threshold: number;
date: string;
commit: string;
commitMessage: string;
version: string;
}
/** An upload row, keyed by the importer's column display names. */
export type StatsRow = Record<string, string | number | null>;
export interface BuildStatsRowsResult {
rows: StatsRow[];
cacheRows: CacheRow[];
significant: boolean;
reason: string;
maxServedDeltaPercent: number;
firstPoint: boolean;
hasNewSeries: boolean;
}
// null, not "", for an absent delta: the stats importer coerces values
// strictly and rejects "" for a numeric column.
const deltaPercent = (current: number, base: number | undefined | null): number | null =>
base ? Math.round(((current - base) * 10000) / base) / 100 : null;
/**
* Turn the current measurements + the last plotted point into upload rows, the
* slim cache rows for the next diff, and the keep/skip decision.
*
* The significance threshold tracks the as-served size: brotli where the build
* ships a precompressed .br, otherwise gzip. Current measurements always carry
* brotliBytes (estimated when no .br shipped), but a cached base point recorded
* before brotli logging may not, so the delta still falls back to gzip per row.
*/
export function buildStatsRows({
measurements,
previous,
threshold,
date,
commit,
commitMessage,
version,
}: BuildStatsRowsInput): BuildStatsRowsResult {
const previousOf = (bundle: string, kind: string) =>
previous.find((row) => row.bundle === bundle && row.kind === kind);
let maxServedDeltaPercent = 0;
let hasNewSeries = false;
const rows = measurements.map((measurement): StatsRow => {
const base = previousOf(measurement.bundle, measurement.kind);
// Non-null only when BOTH sides carry brotli: a point cached before brotli
// logging landed has none, and that row falls back to the gzip delta.
const baseBrotli =
measurement.brotliBytes != null && base?.brotliBytes != null ? base.brotliBytes : null;
const gzipDeltaPercent = deltaPercent(measurement.gzipBytes, base?.gzipBytes);
const brotliDeltaPercent =
baseBrotli != null ? deltaPercent(measurement.brotliBytes, baseBrotli) : null;
if (!base) {
hasNewSeries = true;
} else {
const servedDeltaPercent = baseBrotli != null ? brotliDeltaPercent : gzipDeltaPercent;
maxServedDeltaPercent = Math.max(maxServedDeltaPercent, Math.abs(servedDeltaPercent ?? 0));
}
return {
Date: date,
Version: version,
Commit: commit,
Bundle: measurement.bundle,
Kind: measurement.kind, // "initial" or "total"
// The stats table carries a free-text Description column. Populate it
// with the commit subject so the chart's points are self-describing.
Description: commitMessage,
"Raw bytes": measurement.rawBytes,
"Gzip bytes": measurement.gzipBytes,
"Brotli bytes": measurement.brotliBytes, // as-served (shipped .br, else estimated)
"File count": measurement.fileCount,
"Raw bytes delta": base ? measurement.rawBytes - base.rawBytes : null,
"Gzip bytes delta": base ? measurement.gzipBytes - base.gzipBytes : null,
"Brotli bytes delta": baseBrotli != null ? measurement.brotliBytes - baseBrotli : null,
"Raw delta %": deltaPercent(measurement.rawBytes, base?.rawBytes),
"Gzip delta %": gzipDeltaPercent,
"Brotli delta %": brotliDeltaPercent,
};
});
const firstPoint = previous.length === 0;
const significant = firstPoint || hasNewSeries || maxServedDeltaPercent >= threshold;
// Slim rows the next run diffs against. The workflow only persists this to the
// cache when we actually push, so the cached reference always stays the last
// *plotted* point (cumulative drift is caught).
const cacheRows = measurements.map(
({ bundle, kind, rawBytes, gzipBytes, brotliBytes }): CacheRow => ({
bundle,
kind,
rawBytes,
gzipBytes,
brotliBytes,
}),
);
const reason = firstPoint
? "first point"
: hasNewSeries
? "new bundle/kind series"
: `max served Δ ${maxServedDeltaPercent.toFixed(2)}% (threshold ${threshold}%)`;
return {
rows,
cacheRows,
significant,
reason,
maxServedDeltaPercent,
firstPoint,
hasNewSeries,
};
}
const readJson = <T,>(filePath: string | undefined): T | null =>
filePath && existsSync(filePath) ? (JSON.parse(readFileSync(filePath, "utf8")) as T) : null;
const writeJson = (filePath: string, value: unknown) => {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value));
};
const setOutput = (name: string, value: string) => {
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
// version.properties (extracted from the uberjar) carries the build's tag.
// Non-release (master) builds report tag=vUNKNOWN, so leave Version empty there.
function readVersion(versionPropsPath: string | undefined): string {
const raw =
versionPropsPath && existsSync(versionPropsPath)
? (readFileSync(versionPropsPath, "utf8").match(/^tag=(.*)$/m)?.[1]?.trim() ?? "")
: "";
return raw === "vUNKNOWN" ? "" : raw;
}
export function main() {
const env = process.env;
const measurements = readJson<Measurement[]>(env.CURRENT);
if (!Array.isArray(measurements) || measurements.length === 0) {
console.error(`::error::No bundle measurements found at ${env.CURRENT}`);
process.exit(1);
}
const result = buildStatsRows({
measurements,
// The last plotted point, as slim rows: [{ bundle, kind, rawBytes, gzipBytes, brotliBytes }].
previous: readJson<CacheRow[]>(env.LAST) || [],
threshold: Number(env.MIN_DELTA_PERCENT ?? 1),
// The commit's own time rather than the day it was measured, so the merges
// of one day keep their order on the chart.
date: new Date(env.COMMIT_TIMESTAMP || Date.now()).toISOString(),
commit: (env.HEAD_SHA || "").slice(0, 12),
commitMessage: (env.COMMIT_MESSAGE || "").split("\n")[0], // subject line only
version: readVersion(env.VERSION_PROPS),
});
writeJson(env.ROWS_OUT!, result.rows);
writeJson(env.CACHE_OUT!, result.cacheRows);
console.log(`${result.significant ? "RECORD" : "SKIP"} — ${result.reason}`);
setOutput("significant", result.significant ? "true" : "false");
setOutput(
"max_delta_percent",
result.firstPoint || result.hasNewSeries ? "" : result.maxServedDeltaPercent.toFixed(2),
);
}
// Only run the I/O wrapper when invoked directly, so importing buildStatsRows
// (from the spec) has no side effects.
if ((import.meta as ImportMeta & { main?: boolean }).main) {
main();
}

View File

@@ -1,87 +0,0 @@
const { buildStatsRows } = require("./bundle-size-stats-prepare");
const meta = { date: "2026-06-30", commit: "abc123", commitMessage: "Fix the thing (#123)", version: "" };
const measurement = (over = {}) => ({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
fileCount: 3,
...over,
});
describe("buildStatsRows", () => {
it("records the first point and leaves every delta empty", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement()],
previous: [],
threshold: 1,
});
expect(result.firstPoint).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("first point");
expect(result.rows[0]["Gzip bytes delta"]).toBe("");
expect(result.rows[0]["Brotli delta %"]).toBe("");
expect(result.rows[0]["Description"]).toBe("Fix the thing (#123)");
expect(result.cacheRows[0]).toEqual({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
});
});
it("records a never-seen bundle/kind as a new series", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ bundle: "app", kind: "initial" })],
previous: [{ bundle: "app", kind: "total", rawBytes: 1, gzipBytes: 1, brotliBytes: 1 }],
threshold: 99,
});
expect(result.hasNewSeries).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("new bundle/kind series");
});
it("gates significance on the brotli (as-served) delta when both sides have it", () => {
const previous = [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400, brotliBytes: 300 }];
const big = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 315, gzipBytes: 400 })], // brotli +5%, gzip 0%
previous,
threshold: 1,
});
expect(big.maxServedDeltaPercent).toBeCloseTo(5);
expect(big.significant).toBe(true);
expect(big.rows[0]["Brotli delta %"]).toBeCloseTo(5);
const small = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 301, gzipBytes: 999 })], // brotli +0.33%, gzip ignored
previous,
threshold: 1,
});
expect(small.significant).toBe(false);
expect(small.reason).toContain("threshold 1%");
});
it("falls back to the gzip delta when the cached base predates brotli logging", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ gzipBytes: 440, brotliBytes: 300 })], // gzip +10%
previous: [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400 }],
threshold: 1,
});
expect(result.maxServedDeltaPercent).toBeCloseTo(10);
expect(result.significant).toBe(true);
expect(result.rows[0]["Brotli bytes delta"]).toBe(""); // no base brotli to diff against
expect(result.rows[0]["Gzip delta %"]).toBeCloseTo(10);
});
});

View File

@@ -0,0 +1,105 @@
import {
buildStatsRows,
type CacheRow,
type Measurement,
} from "./bundle-size-stats-prepare";
const meta = {
date: "2026-06-30",
commit: "abc123",
commitMessage: "Fix the thing (#123)",
version: "",
};
const measurement = (over: Partial<Measurement> = {}): Measurement => ({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
fileCount: 3,
...over,
});
describe("buildStatsRows", () => {
it("records the first point and leaves every delta empty", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement()],
previous: [],
threshold: 1,
});
expect(result.firstPoint).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("first point");
expect(result.rows[0]["Gzip bytes delta"]).toBeNull();
expect(result.rows[0]["Brotli delta %"]).toBeNull();
expect(result.rows[0]["Description"]).toBe("Fix the thing (#123)");
expect(result.cacheRows[0]).toEqual({
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
});
});
it("records a never-seen bundle/kind as a new series", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ bundle: "app", kind: "initial" })],
previous: [{ bundle: "app", kind: "total", rawBytes: 1, gzipBytes: 1, brotliBytes: 1 }],
threshold: 99,
});
expect(result.hasNewSeries).toBe(true);
expect(result.significant).toBe(true);
expect(result.reason).toBe("new bundle/kind series");
});
it("gates significance on the brotli (as-served) delta when both sides have it", () => {
const previous: CacheRow[] = [
{
bundle: "embedding-sdk-chunked",
kind: "total",
rawBytes: 1000,
gzipBytes: 400,
brotliBytes: 300,
},
];
const big = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 315, gzipBytes: 400 })], // brotli +5%, gzip 0%
previous,
threshold: 1,
});
expect(big.maxServedDeltaPercent).toBeCloseTo(5);
expect(big.significant).toBe(true);
expect(big.rows[0]["Brotli delta %"]).toBeCloseTo(5);
const small = buildStatsRows({
...meta,
measurements: [measurement({ brotliBytes: 301, gzipBytes: 999 })], // brotli +0.33%, gzip ignored
previous,
threshold: 1,
});
expect(small.significant).toBe(false);
expect(small.reason).toContain("threshold 1%");
});
it("falls back to the gzip delta when the cached base predates brotli logging", () => {
const result = buildStatsRows({
...meta,
measurements: [measurement({ gzipBytes: 440, brotliBytes: 300 })], // gzip +10%
// No brotliBytes: this point was cached before brotli logging landed.
previous: [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400 }],
threshold: 1,
});
expect(result.maxServedDeltaPercent).toBeCloseTo(10);
expect(result.significant).toBe(true);
expect(result.rows[0]["Brotli bytes delta"]).toBeNull(); // no base brotli to diff against
expect(result.rows[0]["Gzip delta %"]).toBeCloseTo(10);
});
});

162
.github/scripts/cache-keys.sh vendored Executable file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Single definition of every CI dependency cache: what it holds, what identifies it, what it falls back to.
#
# A GitHub cache entry is identified by its key, a hash of the declared path list, and the ref that saved
# it. A step declaring a different path list than the one a cache was saved with computes a different
# identity and can never match it, whatever its key says. So paths and keys are emitted together here and
# every cache step takes both from this script.
#
# Emits `name=value` lines in $GITHUB_OUTPUT format:
#
# .github/scripts/cache-keys.sh >> "$GITHUB_OUTPUT"
#
# Run it with no redirect to see the whole scheme, or pass one name to print a single value:
#
# .github/scripts/cache-keys.sh
# .github/scripts/cache-keys.sh m2-key
#
# Hashes are computed here rather than with GitHub's hashFiles() so that a local run and a CI run of the
# same commit produce the same keys, and so that an empty match can fail loudly - a hashFiles() call that
# matches nothing silently yields "", collapsing the key onto its own restore prefix and pointing every
# dependency set at one entry.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
OS="${RUNNER_OS:-$(uname -s)}"
[ "$OS" = "Darwin" ] && OS="macOS"
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | cut -d' ' -f1
else
shasum -a 256 | cut -d' ' -f1
fi
}
# Digest of a set of files: hash each one, then hash the concatenation, so the result depends on every
# file's contents and on which files exist.
digest() {
local f
if [ "$#" -eq 0 ]; then
echo "::error::cache-keys.sh: no files matched; refusing to emit a key that would collide across dependency sets" >&2
exit 1
fi
for f in "$@"; do
[ -f "$f" ] || { echo "::error::cache-keys.sh: $f does not exist" >&2; exit 1; }
sha256 < "$f"
done | sha256
}
shopt -s nullglob
DEPS_FILES=(deps.edn
bin/lint-migrations-file/deps.edn
bin/load-namespaces/deps.edn
modules/drivers/deps.edn
modules/drivers/*/deps.edn)
shopt -u nullglob
DEPS_HASH="$(digest "${DEPS_FILES[@]}")"
LOCK_HASH="$(digest bun.lock)"
# The Cypress binary depends on the Cypress version alone, so keying it on the whole lockfile would
# throw away a 200MB download every time any frontend dependency moved.
#
# Reading a version out of a lockfile with a regex is only safe while exactly one line matches. Insist
# on that rather than taking the first of several: a second match means the format moved under us, and
# picking one at random would key the binary under a version it is not.
CYPRESS_VERSIONS="$(sed -n 's/.*"cypress": \["cypress@\([^"]*\)".*/\1/p' bun.lock)"
CYPRESS_MATCHES="$(printf '%s\n' "$CYPRESS_VERSIONS" | grep -c . || true)"
if [ "$CYPRESS_MATCHES" != "1" ]; then
echo "::error::cache-keys.sh: expected exactly one resolved cypress version in bun.lock, found $CYPRESS_MATCHES" >&2
exit 1
fi
CYPRESS_VERSION="$CYPRESS_VERSIONS"
# The Clojure CLI version CI installs. It lives here rather than in prepare-backend so that the version
# and the key that identifies its cache entry cannot drift apart.
CLOJURE_VERSION="1.12.0.1488"
# An incremental lint cache is only ever an accelerator, so it keys on the commit and always falls back
# to the newest entry on the prefix. The exact key never pre-exists, which keeps the entry current.
ESLINT_SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
emit_multiline() {
local name="$1"; shift
printf '%s<<CACHE_KEYS_EOF\n' "$name"
printf '%s\n' "$@"
printf 'CACHE_KEYS_EOF\n'
}
# Paths under the home directory are emitted absolute rather than as `~/...`. actions/cache would expand
# a tilde, but a shell assigning one of these to a variable would not, and would go on to create a
# directory named `~`. Paths inside the checkout stay relative; actions/cache resolves those against the
# workspace.
spec() {
emit_multiline m2-path "$HOME/.m2" "$HOME/.gitlibs" "$HOME/.deps.clj" 'bin/bb'
echo "m2-key=M2-$OS-$DEPS_HASH"
echo "m2-restore-key=M2-$OS-"
# Pinned rather than discovered via `bun pm cache` so that the path is known before bun is installed;
# prepare-frontend exports BUN_INSTALL_CACHE_DIR to match and fails if bun disagrees.
echo "bun-store-path=$HOME/.bun/install/cache"
echo "bun-store-key=bun-store-$OS-$LOCK_HASH"
echo "bun-store-restore-key=bun-store-$OS-"
# No restore-key: an entry from another version installs a working `clojure` of the wrong version, and
# the freshness check in prepare-backend - running the binary - would accept it.
#
# The installer bakes this prefix into the clj/clojure shims, so the tree only works when restored to
# the same absolute path it was built at; prepare-backend reinstalls when it is not.
echo "clojure-path=$HOME/.clojure-cli"
echo "clojure-key=clojure-cli-$OS-$CLOJURE_VERSION"
echo "clojure-version=$CLOJURE_VERSION"
echo "cypress-path=$HOME/.cache/Cypress"
echo "cypress-key=cypress-$OS-$CYPRESS_VERSION"
echo "cypress-restore-key=cypress-$OS-"
echo "eslint-path=.eslintcache"
echo "eslint-key=eslint-$OS-$ESLINT_SHA"
echo "eslint-restore-key=eslint-$OS-"
}
# Print one output's value, including the multiline form, and fail on a name that does not exist. A
# caller that silently received "" would go on to build a key with a hole in it.
query() {
local want="$1" line delim found=0 in_block=0
while IFS= read -r line; do
if [ "$in_block" = 1 ]; then
if [ "$line" = "$delim" ]; then
in_block=0
else
printf '%s\n' "$line"
fi
continue
fi
case "$line" in
"$want="*)
printf '%s\n' "${line#*=}"
found=1
;;
"$want<<"*)
delim="${line#*<<}"
in_block=1
found=1
;;
esac
done <<EOF
$(spec)
EOF
if [ "$found" = 0 ]; then
echo "::error::cache-keys.sh: no output named '$want'" >&2
exit 1
fi
}
if [ "$#" -eq 0 ]; then
spec
else
query "$1"
fi

80
.github/scripts/cache-keys.unit.spec.ts vendored Normal file
View File

@@ -0,0 +1,80 @@
import { execFileSync } from "child_process";
import { join } from "path";
const SCRIPT = join(__dirname, "cache-keys.sh");
const CACHES = ["m2", "bun-store", "cypress", "eslint"];
// stderr is inherited by default, so the deliberate-failure case below would print a literal
// `::error::` line - which GitHub Actions reads as an annotation directive, making a passing run
// annotate itself with an error.
const run = (args: string[] = [], env: NodeJS.ProcessEnv = {}, quiet = false) =>
execFileSync(SCRIPT, args, {
encoding: "utf8",
stdio: quiet ? ["ignore", "pipe", "ignore"] : "pipe",
env: { ...process.env, RUNNER_OS: "Linux", GITHUB_SHA: "abc123", ...env },
});
const get = (name: string, env: NodeJS.ProcessEnv = {}) =>
run([name], env).trimEnd();
describe("cache-keys.sh", () => {
it("is deterministic for a given tree", () => {
expect(run()).toEqual(run());
});
it("emits every cache's path, key and restore key", () => {
for (const cache of CACHES) {
expect(get(`${cache}-path`)).not.toEqual("");
expect(get(`${cache}-key`)).not.toEqual("");
expect(get(`${cache}-restore-key`)).not.toEqual("");
}
});
// Silently returning "" for a name that does not exist is how a caller ends up building a key with a
// hole in it.
it("fails on an unknown output name", () => {
expect(() => run(["no-such-output"], {}, true)).toThrow();
});
// A key equal to its own restore prefix points every dependency set at one entry, and nothing reports
// it. This is the failure the script exists to prevent.
it("never emits a key equal to its own restore prefix", () => {
for (const cache of CACHES) {
const key = get(`${cache}-key`);
const prefix = get(`${cache}-restore-key`);
expect(key.startsWith(prefix)).toBe(true);
expect(key.length).toBeGreaterThan(prefix.length);
}
});
it("scopes every key to the runner OS", () => {
for (const cache of CACHES) {
expect(get(`${cache}-key`, { RUNNER_OS: "Linux" })).not.toEqual(
get(`${cache}-key`, { RUNNER_OS: "macOS" }),
);
}
});
// Home paths must be absolute: actions/cache expands a tilde but a shell assigning one to a variable
// does not, and would create a directory literally named `~`.
it("emits absolute paths for home directory locations", () => {
expect(get("bun-store-path").startsWith("/")).toBe(true);
expect(get("cypress-path").startsWith("/")).toBe(true);
for (const line of get("m2-path").split("\n")) {
expect(line.startsWith("~")).toBe(false);
}
});
// The Cypress binary depends on the Cypress version alone, so an unrelated dependency bump must not
// invalidate a 200MB download.
it("keys the Cypress binary on the resolved version, not the lockfile", () => {
expect(get("cypress-key")).toMatch(/^cypress-Linux-\d+\.\d+\.\d+/);
});
it("keys the ESLint cache on the commit", () => {
expect(get("eslint-key", { GITHUB_SHA: "deadbeef" })).toEqual(
"eslint-Linux-deadbeef",
);
});
});

71
.github/scripts/check-job-timeouts.sh vendored Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Fails when a job in .github/workflows/ does not set `timeout-minutes`.
#
# Without it a job inherits GitHub's 6-hour default, so a hung job burns six hours of runner time -
# and holds up everything queued behind it - before anyone notices. actionlint cannot check this: its
# rule set is compiled in and .github/actionlint.yaml only takes `self-hosted-runner`,
# `config-variables`, and `paths` (which suppresses findings rather than adding them). Hence this
# script.
#
# The rule is per job: a `timeout-minutes` on a step does not satisfy it, and steps are not required
# to carry one of their own.
#
# Jobs that call a reusable workflow (`uses:` at the job level) are skipped - GitHub rejects
# `timeout-minutes` there. The called workflow's own jobs are checked when it lives in this repo.
#
# .github/scripts/check-job-timeouts.sh [workflow-file ...]
#
# With no arguments it checks every workflow; CI passes the files that changed, like actionlint does.
set -euo pipefail
# Arguments are used as given, so the paths in the annotations match what the caller passed. Without
# them the full scan has to run from the repo root.
if [ $# -gt 0 ]; then
workflows=("$@")
else
cd "$(git rev-parse --show-toplevel)"
shopt -s nullglob
workflows=(.github/workflows/*.yml .github/workflows/*.yaml)
if [ ${#workflows[@]} -eq 0 ]; then
echo "::error::check-job-timeouts.sh: no workflow files found under .github/workflows/" >&2
exit 1
fi
fi
status=0
for file in "${workflows[@]}"; do
# `awk NF` drops the blank line yq emits when a file has nothing to report.
missing="$(
# --header-preprocess=false: yq otherwise slurps any leading comment block before parsing, which
# shifts every line number it reports by the size of that block.
yq --header-preprocess=false '
.jobs
| to_entries
| .[]
| select(((.value | has("timeout-minutes")) or (.value | has("uses"))) | not)
| [(.key | line), .key]
| @tsv
' "$file" | awk NF
)"
[ -n "$missing" ] || continue
while IFS=$'\t' read -r line job; do
echo "::error file=${file},line=${line}::Job \"${job}\" does not set a job-level timeout-minutes" >&2
done <<< "$missing"
status=1
done
if [ "$status" -ne 0 ]; then
echo >&2
echo "Every job must set an explicit timeout-minutes; without one it inherits GitHub's 6-hour default." >&2
echo "Pick a value with headroom over the job's normal runtime." >&2
exit 1
fi
echo "All jobs in the ${#workflows[@]} workflow file(s) checked set timeout-minutes."

109
.github/scripts/check-preresolve-aliases.sh vendored Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Fails when a Clojure classpath used by CI is not pre-resolved by cache-warm.
#
# tools.deps selects dependency versions globally per classpath, so two alias combinations can resolve
# the same transitive library to different versions. A combination the warm job never resolved is
# therefore not merely absent from the cache - the cache holds *a* version of those libraries, just not
# the one this classpath wants, and the job fetches its delta straight from Maven Central on every run.
# That is the intermittent-403 failure of DEV-2094, and it is invisible: the build still passes.
#
# Nothing about a missing combination shows up at runtime, so this check is the only thing standing
# between a newly added alias combination and a permanently half-cold cache.
#
# .github/scripts/check-preresolve-aliases.sh
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
WARM=.github/workflows/cache-warm.yml
# Invocations deliberately left out of the warm job. Each needs a reason, because the default answer is
# to add the combination to the list instead.
WAIVED="$(
cat <<'EOF'
:deps
:outdated
EOF
)"
# :deps - tools.deps built-in used by write-poms.sh; contributes no dependencies of its own.
# :outdated - scheduled dependency-bump workflow only; a cold resolve there costs nothing anyone waits on.
# Dependency roots and alias combinations the warm job pre-resolves.
declared="$({
sed -n '/^[[:space:]]*combos=(/,/^ *)/p' "$WARM" \
| grep -oE '":[^"]*"' \
| tr -d '"' \
| sed 's#^#.|#'
sed -n '/^[[:space:]]*nested_combos=(/,/^ *)/p' "$WARM" \
| grep -oE '"[^"]+\|:[^"]+"' \
| tr -d '"'
} | sort -u)"
if [ -z "$declared" ]; then
echo "::error::check-preresolve-aliases.sh: found no pre-resolved classpaths in $WARM" >&2
exit 1
fi
# Expressions we know how to expand. An unrecognised one has to stop the check rather than be skipped:
# silently ignoring it is how a whole matrix of combinations would go unchecked.
unknown="$(grep -rhoE '\$\{\{[^}]*\}\}' --include='*' \
--exclude='*.png' --exclude='*.jpg' .github/ 2>/dev/null \
| sort -u | grep -v 'edition' || true)"
scan() { # scan <edition>: dependency root and aliases used by .github Clojure invocations
grep -rhE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:' \
--include='*' --exclude='*.png' --exclude='*.jpg' .github/ 2>/dev/null \
| sed "s/\\\${{[^}]*edition[^}]*}}/$1/g" \
| grep -oE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:[A-Za-z0-9:_./-]+' \
| grep -oE ':[A-Za-z0-9:_./-]+' \
| sed -e 's/:$//' -e 's#^#.|#'
}
scan_project_tests() { # scan_project_tests <function> <dependency-root>
# Mage shell commands are vectors split across lines, so flatten each function body before
# extracting its alias.
sed -n "/(defn- $1 /,/^(defn/p" mage/src/mage/project_tests.clj \
| tr '\n' ' ' \
| grep -oE '"clojure"[[:space:]]+("-P"[[:space:]]+)?"-[XMATP]?:[A-Za-z0-9:_./-]+' \
| grep -oE ':[A-Za-z0-9:_./-]+' \
| sed -e 's/:$//' -e "s#^#$2|#"
}
# Check each scan separately. One scan still finding commands would otherwise conceal that another has
# stopped matching, which silently drops everything it used to cover.
require_scan() { # require_scan <what> <result>
[ -n "$2" ] && return
echo "::error::check-preresolve-aliases.sh: found no Clojure invocation in $1; the scan pattern no longer matches" >&2
exit 1
}
workflow_checks=$( { scan ee; scan oss; } || true)
clojure_checks=$(scan_project_tests run-clojure-checks! . || true)
migration_checks=$(scan_project_tests run-migration-checks! bin/lint-migrations-file || true)
require_scan ".github/" "$workflow_checks"
require_scan "run-clojure-checks! (mage/src/mage/project_tests.clj)" "$clojure_checks"
require_scan "run-migration-checks! (mage/src/mage/project_tests.clj)" "$migration_checks"
scanned="$(printf '%s\n' "$workflow_checks" "$clojure_checks" "$migration_checks" | sort -u)"
found="$( {
printf '%s\n' "$scanned"
# build-scripts.yml runs this classpath from its own dependency root.
printf '%s\n' 'bin/load-namespaces|:test'
} | sort -u )"
covered="$( {
printf '%s\n' "$declared"
printf '%s\n' "$WAIVED" | sed 's#^#.|#'
} | sort -u)"
missing="$(comm -23 <(printf '%s\n' "$found") <(printf '%s\n' "$covered") || true)"
if [ -n "$missing" ]; then
echo "::error::Clojure classpaths used by CI that cache-warm does not pre-resolve:" >&2
printf '%s\n' "$missing" | sed 's/^/ /' >&2
echo >&2
echo "Add each to the matching root or nested combo list in $WARM, or waive it here with a reason." >&2
echo "Leaving it uncovered means those jobs re-fetch their version deltas from Maven on every run." >&2
exit 1
fi
echo "All $(printf '%s\n' "$found" | grep -c .) Clojure classpaths used by CI are pre-resolved."

View File

@@ -0,0 +1,182 @@
import { spawnSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const TEST_UNIT_COMMAND = ["bun", "run", "test-unit-keep-cljs"];
type SuiteResult = {
name: string;
status: string;
assertionResults: unknown[];
};
type JestResults = {
wasInterrupted: boolean;
testResults: SuiteResult[];
};
function isSuiteResult(value: unknown): value is SuiteResult {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string" &&
"status" in value &&
typeof value.status === "string" &&
"assertionResults" in value &&
Array.isArray(value.assertionResults)
);
}
function readResults(file: string): JestResults | null {
const value = readJson(file);
if (
typeof value === "object" &&
value !== null &&
"wasInterrupted" in value &&
typeof value.wasInterrupted === "boolean" &&
"testResults" in value &&
Array.isArray(value.testResults) &&
value.testResults.every(isSuiteResult)
) {
return {
wasInterrupted: value.wasInterrupted,
testResults: value.testResults,
};
}
return null;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function readJson(file: string): unknown {
try {
return parseJson(readFileSync(file, "utf8"));
} catch {
return null;
}
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
function hasContent(file: string): boolean {
try {
return statSync(file).size > 0;
} catch {
return false;
}
}
export function findRunnerFailure(env: NodeJS.ProcessEnv): string | null {
const {
UNIT_TEST_RESULTS_FILE,
JEST_JUNIT_OUTPUT_DIR,
JEST_JUNIT_OUTPUT_NAME,
} = env;
if (
!UNIT_TEST_RESULTS_FILE ||
!JEST_JUNIT_OUTPUT_DIR ||
!JEST_JUNIT_OUTPUT_NAME
) {
throw new Error(
"UNIT_TEST_RESULTS_FILE, JEST_JUNIT_OUTPUT_DIR and JEST_JUNIT_OUTPUT_NAME are required",
);
}
const results = readResults(UNIT_TEST_RESULTS_FILE);
if (results === null || results.wasInterrupted) {
return "Jest did not finish the run.";
}
if (!hasContent(join(JEST_JUNIT_OUTPUT_DIR, JEST_JUNIT_OUTPUT_NAME))) {
return "Jest did not write a JUnit report.";
}
const failedSuites = results.testResults.filter(
(suite) => suite.status === "failed",
);
// jest-junit leaves out suites with no test results, so the quarantine gate never sees them.
const suitesWithoutResults = failedSuites.filter(
(suite) => suite.assertionResults.length === 0,
);
if (suitesWithoutResults.length > 0) {
const names = suitesWithoutResults.map((suite) => suite.name).join(", ");
return `Test suites failed to run: ${names}`;
}
if (failedSuites.length === 0) {
return "Jest failed without a failing test suite.";
}
return null;
}
function listTests(
command: readonly string[],
env: NodeJS.ProcessEnv,
): string[] | null {
const [program, ...args] = command;
const result = spawnSync(program, [...args, "--listTests", "--json"], {
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
if (result.status !== 0) {
return null;
}
const listed = parseJson(result.stdout);
return isStringArray(listed) ? listed : null;
}
// --passWithNoTests lets every shard pass when the selection matches nothing Jest discovers.
// A single empty shard is normal, so only an unsharded listing that is also empty warns.
export function findEmptySelection(
env: NodeJS.ProcessEnv,
command: readonly string[] = TEST_UNIT_COMMAND,
): string | null {
const { UNIT_TEST_RESULTS_FILE, JEST_TEST_PATHS_FILE } = env;
if (!UNIT_TEST_RESULTS_FILE || !JEST_TEST_PATHS_FILE) {
return null;
}
const results = readResults(UNIT_TEST_RESULTS_FILE);
const selection = readJson(JEST_TEST_PATHS_FILE);
if (
results === null ||
results.testResults.length > 0 ||
!isStringArray(selection) ||
selection.length === 0
) {
return null;
}
const listed = listTests(command, env);
if (listed === null) {
return "Could not list Jest's unit tests to check the selection.";
}
if (listed.length > 0) {
return null;
}
return `None of the ${selection.length} selected unit specs match a test Jest discovers, so no unit tests ran.`;
}
if (require.main === module) {
if (process.env.TEST_OUTCOME === "failure") {
const failure = findRunnerFailure(process.env);
if (failure !== null) {
console.error(`::error::${failure}`);
process.exitCode = 1;
}
} else {
const warning = findEmptySelection(process.env);
if (warning !== null) {
console.warn(`::warning::${warning}`);
}
}
}

View File

@@ -1,109 +0,0 @@
const TYPES = new Set([
"feat",
"fix",
"perf",
"docs",
"style",
"refactor",
"test",
"build",
"ci",
]);
/**
* @param title {string}
*/
function isConventionalTitle(title) {
const match = /^(?<type>\w+)\((?<rawScopes>[^)]+)\):/.exec(title);
if (!match) {
return false;
}
const [, type, rawScopes] = match;
const scopes = rawScopes
.split(",")
.map((scope) => scope.trim())
.filter(Boolean);
return TYPES.has(type) && scopes.length > 0;
}
/** @param {import('@types/github-script').AsyncFunctionArguments} AsyncFunctionArguments */
async function updateCommentForEmbedding({ github, context }) {
const githubUsername = context.payload.sender.login;
const comment = `${CONVENTIONAL_COMMENT_IDENTIFIER}
@${githubUsername} You have modified embedding code. Please make sure the PR title follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) style.
Here are all the supported types:
- \`feat\`
- \`fix\`
- \`perf\`
- \`docs\`
- \`style\`
- \`refactor\`
- \`test\`
- \`build\`
- \`ci\`
Please also make sure to include a scope.
For example, these are valid PR titles:
- \`feat(sdk-bundle): Add interactive dashboard component\`
- \`fix(sdk-package): Fixed MetabaseProviderPropsStore logic\`
- \`fix(sdk-package, embed-js): Fix API\``;
return updateComment(comment, { github, context });
}
/**
* @param comment {string}
* @param {import('@types/github-script').AsyncFunctionArguments} AsyncFunctionArguments
*/
async function updateComment(comment, { github, context }) {
const owner = context.repo.owner;
const repo = context.repo.repo;
const pullRequestNumber = context.issue.number;
const comments = await github.rest.issues.listComments({
owner,
repo,
issue_number: pullRequestNumber,
});
const existingComment = getExistingConventionalCommitReminderComment(
comments.data,
);
if (existingComment) {
return await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: comment,
});
} else {
return await github.rest.issues.createComment({
owner,
repo,
issue_number: pullRequestNumber,
body: comment,
});
}
}
const CONVENTIONAL_COMMENT_IDENTIFIER =
"<!---conventional comment identifier-->";
/**
* @param comments {{body: string}[]}
*/
function getExistingConventionalCommitReminderComment(comments) {
for (const comment of comments) {
if (comment.body.startsWith(CONVENTIONAL_COMMENT_IDENTIFIER)) {
return comment;
}
}
}
exports.isConventionalTitle = isConventionalTitle;
exports.updateCommentForEmbedding = updateCommentForEmbedding;

View File

@@ -1,45 +0,0 @@
const { isConventionalTitle } = require("./utils");
describe("isConventionalTitle", () => {
it("returns true for a simple conventional commit", () => {
expect(isConventionalTitle("feat: add new feature")).toBe(false);
});
it("returns true for a commit with a scope", () => {
expect(isConventionalTitle("fix(api): correct API bug")).toBe(true);
});
it("returns true for a commit with multiple scopes, comma separated", () => {
expect(isConventionalTitle("feat(build,deps): update dependencies")).toBe(
true,
);
});
it("returns true for a commit with multiple scopes, comma separated with space", () => {
expect(isConventionalTitle("feat(build, deps): update dependencies")).toBe(
true,
);
});
it("returns false for a commit with round brackets in a message", () => {
expect(isConventionalTitle("feat: (something) update dependencies")).toBe(
false,
);
});
it("returns false for missing colon", () => {
expect(isConventionalTitle("feat add feature")).toBe(false);
});
it("returns false for invalid type", () => {
expect(isConventionalTitle("invalidType: some message")).toBe(false);
});
it("returns false for empty message", () => {
expect(isConventionalTitle("feat:")).toBe(false);
});
it("returns false for empty input", () => {
expect(isConventionalTitle("")).toBe(false);
});
});

View File

@@ -1,37 +1,45 @@
// I/O entrypoint: gather inputs (env vars, the cruise graph, the test-file
// lists) and hand them to createTestPlan, which does the computing.
// I/O entrypoint: gather inputs (env vars, the test-file lists, the cruise graph on request),
// hand them to createTestPlan, which does the computing, and publish its small stats output.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import micromatch from "micromatch";
import { MAIN_APP_STORY_GLOBS } from "../../.storybook/story-files.cjs";
import { elements, rules } from "../../frontend/lint/module-boundaries.mjs";
import { type FileDependency, parseCruiseModules } from "./affected-modules";
import { createTestPlan } from "./affected-tests";
import { listSpecFiles } from "./e2e-spec-globs.mjs";
const UNIT_ROOTS = ["frontend/src", "enterprise/frontend/src"];
// The specs `bun run test-unit` runs.
const UNIT_ROOTS = ["."];
const UNIT_GLOBS = [
"frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
"enterprise/frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
"**/*.unit.spec.{js,jsx,ts,tsx}",
"!.github/**", // the ci-scripts project, which test-unit ignores
"!release/**", // has its own jest config
"!enterprise/frontend/src/custom-viz/**", // modulePathIgnorePatterns in jest.config.js
"!**/*.leak.unit.spec.{js,jsx,ts,tsx}", // run only by jest.memory.conf.js
];
const STORY_ROOTS = ["frontend", "enterprise/frontend"];
const STORY_GLOBS = [
"frontend/**/*.stories.{js,jsx,ts,tsx}",
"enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}",
];
const FRONTEND_ROOTS = ["frontend", "enterprise/frontend"];
const LOKI_PREVIEW_FILE = ".storybook/preview.tsx";
// `git ls-files -- frontend enterprise/frontend` already prints just over a
// megabyte of paths, and node's default maxBuffer is exactly 1 MiB: past that
// the spawn dies with ENOBUFS. Give the listings room to grow.
const LS_FILES_MAX_BUFFER = 64 * 1024 * 1024;
// Returns the tracked files under `roots` that match `globs`. The `dot: true`
// option means files inside dot-directories such as `.storybook` are included.
function listFiles(roots: string[], globs: string[]): string[] {
const tracked = execFileSync("git", ["ls-files", "--", ...roots], {
const tracked = execFileSync("git", ["ls-files", "-z", "--", ...roots], {
encoding: "utf8",
maxBuffer: LS_FILES_MAX_BUFFER,
})
.split("\n")
.map((line) => line.trim())
.split("\0")
.filter(Boolean);
return micromatch(tracked, globs, { dot: true });
}
@@ -43,26 +51,43 @@ const csvToList = (csv: string | undefined) =>
.map((s) => s.trim())
.filter(Boolean);
// Reads the dependency-cruiser graph (DEP_GRAPH_JSON). Null falls back to the
// rules graph, so a missing or unparseable file never breaks the plan.
function readFileDependencies(): FileDependency[] | null {
const path = process.env.DEP_GRAPH_JSON;
if (path && existsSync(path)) {
try {
const { modules } = JSON.parse(readFileSync(path, "utf8"));
process.stderr.write(`Using usage graph from ${path}.\n`);
return parseCruiseModules(modules);
} catch (error) {
process.stderr.write(
`Failed to read ${path}; falling back to rules graph: ${error}\n`,
);
}
} else {
process.stderr.write(
"No DEP_GRAPH_JSON found; falling back to rules graph.\n",
// Runs dependency-cruiser over the frontend sources and the Storybook preview and parses its edges.
// Null falls back to the rules graph, so a failed cruise never breaks the plan.
function loadFileDependencies(): FileDependency[] | null {
const output = "dependency-graph.json";
process.stderr.write("Building usage graph with dependency-cruiser.\n");
try {
// stdout is the plan JSON, so the cruise's own output goes to stderr.
const result = spawnSync(
"bunx",
[
"depcruise",
"frontend/src",
"enterprise/frontend/src",
LOKI_PREVIEW_FILE,
"--config",
".dependency-cruiser.cjs",
"--output-type",
"json",
"--output-to",
output,
],
{ stdio: ["ignore", 2, 2] },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`depcruise exited with status ${result.status}`);
}
const { modules } = JSON.parse(readFileSync(output, "utf8"));
return parseCruiseModules(modules);
} catch (error) {
process.stderr.write(
`Failed to build usage graph; falling back to rules graph: ${error}\n`,
);
return null;
}
return null;
}
// Reads the nightly coverage manifest (E2E_SPEC_MANIFEST): { builtAt, specs:
@@ -92,12 +117,13 @@ const testPlan = createTestPlan({
elements,
rules,
changedFiles: csvToList(process.env.CHANGED_FILES),
fileDependencies: readFileDependencies(),
loadFileDependencies,
testFilesBySuite: {
unit: listFiles(UNIT_ROOTS, UNIT_GLOBS),
loki: listFiles(STORY_ROOTS, STORY_GLOBS),
loki: listFiles(FRONTEND_ROOTS, MAIN_APP_STORY_GLOBS),
e2e: listSpecFiles(),
},
lokiPreviewFile: LOKI_PREVIEW_FILE,
e2eSpecFiles: readE2eSpecFiles(),
unitInfraTouched: process.env.UNIT_INFRA_TOUCHED === "true",
lokiInfraTouched: process.env.LOKI_INFRA_TOUCHED === "true",
@@ -105,8 +131,15 @@ const testPlan = createTestPlan({
sharedSourcesTouched: process.env.SHARED_SOURCES_TOUCHED === "true",
feFilesChanged: csvToList(process.env.FE_CHANGED_FILES).length,
beFilesChanged: csvToList(process.env.BE_CHANGED_FILES).length,
feFilesTotal: listFiles(["frontend", "enterprise/frontend"], ["**"]).length,
feFilesTotal: listFiles(FRONTEND_ROOTS, ["**"]).length,
beFilesTotal: listFiles(["src", "enterprise/backend"], ["**"]).length,
});
process.stdout.write(JSON.stringify(testPlan) + "\n");
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`stats=${JSON.stringify(testPlan.stats)}\n`,
);
}

View File

@@ -1,91 +0,0 @@
/**
* jsonData must be in the format:
* [
* { column1: value1, column2: value2, ... },
* { column1: value3, column2: value4, ... },
* ...
* ]
*/
function jsonToCsvFormData(jsonData) {
const formData = new FormData();
const csvHeader = Object.keys(jsonData[0]).join(',') + '\n';
const csvContent = jsonData.map(row =>
Object.values(row).map(value => `"${String(value).replace(/"/g, '""')}"`).join(',')
).join('\n');
const blob = new Blob([csvHeader + csvContent], { type: 'text/csv' });
formData.append('file', blob, 'data.csv');
return formData;
}
// Transient failures worth retrying: throttling and server-side errors. A 4xx
// (bad request, auth) won't succeed on retry, so those fail fast.
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
class UploadError extends Error {
constructor(message, { retryable }) {
super(message);
this.retryable = retryable;
}
}
async function attemptUpload(url, jsonData, timeoutMs) {
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
"x-api-key": process.env.API_KEY
},
// Rebuilt per attempt: a consumed request body can't be reused.
body: jsonToCsvFormData(jsonData),
// Without this the request can hang indefinitely on a stalled server,
// eating the whole job timeout. An abort surfaces below as a retryable
// network error, so it retries with backoff instead.
signal: AbortSignal.timeout(timeoutMs)
});
} catch (networkError) {
throw new UploadError(networkError.message, { retryable: true });
}
if (response.ok) {
return;
}
const body = await response.text().catch(() => '');
throw new UploadError(
`Upload failed: ${response.status} ${response.statusText} ${body}`.trim(),
{ retryable: RETRYABLE_STATUS.has(response.status) }
);
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
/**
* Uploads jsonData as CSV, retrying transient API errors with exponential
* backoff. `retries` is the number of extra attempts after the first (so the
* default 2 means up to 3 tries). `timeoutMs` is a per-attempt total deadline
* (default 60s) that aborts a stalled request. Throws the last error once
* attempts are exhausted or the error is non-retryable.
*/
async function uploadCsvToMb({ baseUrl, tableId, jsonData, mode = 'append', retries = 2, retryDelayMs = 1000, timeoutMs = 60_000 }) {
const operation = mode === 'replace' ? 'replace-csv' : 'append-csv';
const url = `${baseUrl}/api/table/${tableId}/${operation}`;
for (let attempt = 0; ; attempt++) {
try {
await attemptUpload(url, jsonData, timeoutMs);
return { success: true };
} catch (error) {
if (!error.retryable || attempt >= retries) {
throw error;
}
const delay = retryDelayMs * 2 ** attempt;
console.warn(`Upload attempt ${attempt + 1} failed (${error.message}); retrying in ${delay}ms`);
await sleep(delay);
}
}
}
module.exports = { uploadCsvToMb };

View File

@@ -1,50 +0,0 @@
const { uploadCsvToMb } = require("./csv-to-mb");
const ok = () => ({ ok: true });
const fail = status => ({ ok: false, status, statusText: "err", text: async () => "" });
const upload = (over = {}) =>
uploadCsvToMb({
baseUrl: "https://stats.example.com",
tableId: 1,
jsonData: [{ a: 1 }],
retryDelayMs: 1,
...over,
});
describe("uploadCsvToMb retries", () => {
afterEach(() => {
delete global.fetch;
});
it("retries transient server errors then succeeds", async () => {
global.fetch = jest.fn().mockResolvedValueOnce(fail(503)).mockResolvedValueOnce(fail(500)).mockResolvedValueOnce(ok());
await expect(upload({ retries: 2 })).resolves.toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it("retries network errors", async () => {
global.fetch = jest
.fn()
.mockRejectedValueOnce(new Error("ECONNRESET"))
.mockResolvedValueOnce(ok());
await expect(upload({ retries: 2 })).resolves.toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("gives up after exhausting retries and throws the last error", async () => {
global.fetch = jest.fn().mockResolvedValue(fail(500));
await expect(upload({ retries: 2 })).rejects.toThrow("Upload failed: 500");
expect(global.fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it("does not retry a non-transient 4xx", async () => {
global.fetch = jest.fn().mockResolvedValue(fail(400));
await expect(upload({ retries: 2 })).rejects.toThrow("Upload failed: 400");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

142
.github/scripts/file-paths.unit.spec.ts vendored Normal file
View File

@@ -0,0 +1,142 @@
import { readFileSync } from "fs";
import { join } from "path";
import { load } from "js-yaml";
import { isMatch } from "micromatch";
const FILTERS = join(__dirname, "..", "file-paths.yaml");
type Filter = string | Filter[] | { [changeType: string]: Filter };
// A filter is a list of patterns, and an entry may name the change types it applies to as
// { "added|modified": pattern }. Either way the patterns are what matters here.
const patterns = (value: Filter): string[] => {
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.flatMap(patterns);
}
return Object.values(value).flatMap(patterns);
};
describe("file-paths.yaml", () => {
// js-yaml returns an untyped value, and this repository file defines named path filters.
const filters = load(readFileSync(FILTERS, "utf8")) as Record<string, Filter>;
// dorny/paths-filter matches with picomatch, which micromatch wraps, so this asks the same
// question CI asks of a changed path.
const matches = (name: string, path: string) =>
patterns(filters[name]).some((pattern) =>
isMatch(path, pattern, { dot: true }),
);
// dorny/paths-filter ORs the patterns in a filter, so a "!..." entry does not subtract from the
// other entries - it matches every path they don't, turning the filter permanently on. Excluding
// a path means writing the exclusion inside one pattern, the way backend_sources keeps
// ratchet files out with ".clj-kondo/**/!(ratchets.edn)".
it("never excludes a path with a standalone negation", () => {
const negated = Object.entries(filters).flatMap(([name, filter]) =>
patterns(filter)
.filter((pattern) => pattern.startsWith("!"))
.map((pattern) => `${name}: ${pattern}`),
);
expect(negated).toEqual([]);
});
it.each(["backend_sources", "backend_all"])(
"%s skips a ratchets-only change and still follows the rest of .clj-kondo",
(name) => {
expect(matches(name, ".clj-kondo/ratchets.edn")).toBe(false);
expect(matches(name, ".clj-kondo/config/modules/ratchets.edn")).toBe(
false,
);
expect(matches(name, ".clj-kondo/config.edn")).toBe(true);
expect(matches(name, ".clj-kondo/config/modules/config.edn")).toBe(true);
},
);
it("runs CI script tests when the Node version changes", () => {
expect(matches("ci_scripts", ".nvmrc")).toBe(true);
});
it.each([
"jest.config.js",
"jest.base.conf.js",
"jest.esm-packages.js",
"jest.tz.unit.conf.js",
])("runs every frontend unit test when %s changes", (path) => {
expect(matches("frontend_specs", path)).toBe(true);
expect(matches("frontend_unit_infra", path)).toBe(true);
});
it("runs every frontend unit test when an SDK jest setup file changes", () => {
expect(
matches(
"frontend_unit_infra",
"frontend/src/embedding-sdk-shared/jest/setup-env.ts",
),
).toBe(true);
});
it.each([
"jest.config.js",
"jest.base.conf.js",
"jest.esm-packages.js",
".storybook/story-files.cjs",
".github/workflows/loki.yml",
])("runs CI script tests when %s changes", (path) => {
expect(matches("ci_scripts", path)).toBe(true);
});
it("runs the ratchet check on the ratchets file", () => {
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
true,
);
expect(
matches(
"project_ratchet_checks",
".clj-kondo/config/modules/ratchets.edn",
),
).toBe(true);
});
it.each([
"loki.config.js",
".storybook/story-files.cjs",
".storybook/preview.tsx",
".loki/reference/chrome.laptop-Button.png",
".github/workflows/loki.yml",
".github/workflows/run-tests.yml",
".github/actions/prepare-frontend/action.yml",
"frontend/build/shared/rspack/css-config.js",
"frontend/test/__support__/custom-viz-fixtures/calendar-heatmap/index.js",
"patches/@loki+browser+0.35.0.patch",
"frontend/src/metabase/css/index.module.css",
"frontend/src/metabase/css/core/base.module.css",
"resources/frontend_client/app/fonts/Lato/lato-v16-latin-regular.woff2",
"resources/frontend_client/app/assets/img/no_results.svg",
"frontend/src/metabase/ui/components/icons/Icon/icons/warning.svg",
"enterprise/frontend/src/metabase-enterprise/google_drive/database-error.svg",
])("runs all Loki stories when %s changes", (file) => {
expect(matches("frontend_loki_all", file)).toBe(true);
expect(matches("frontend_loki_infra", file)).toBe(true);
});
it.each([
"frontend/src/metabase/ui/Button.stories.tsx",
"frontend/src/metabase/ui/components/feedback/Alert/Alert.module.css",
"frontend/build/embedding-sdk/rspack.config.js",
])("allows a change to %s to narrow the Loki run", (file) => {
expect(matches("frontend_loki_all", file)).toBe(true);
expect(matches("frontend_loki_infra", file)).toBe(false);
});
it.each([
".github/workflows/frontend.yml",
"frontend/test/metabase/scenarios/Button.unit.spec.tsx",
])("does not run Loki stories when %s changes", (file) => {
expect(matches("frontend_loki_all", file)).toBe(false);
});
});

View File

@@ -1,40 +0,0 @@
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
query {
attachmentsForURL(url: "${githubIssueUrl}") {
nodes {
issue { identifier }
}
}
}
`,
}),
});
const data = await response.json();
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
if (!data.data?.attachmentsForURL?.nodes?.length) {
return {
success: false,
message: `No Linear issue linked to ${githubIssueUrl}`,
};
}
return {
success: true,
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
};
}
module.exports = { findLinearIssue };

View File

@@ -0,0 +1,66 @@
/**
* Renames aliased dependency keys in a bun-generated yarn.lock.
*
* Yarn names an alias entry after the alias, for example
* `"typescript7@npm:typescript@7.0.2"`. Bun names it after the aliased package
* instead, for example `"typescript@npm:typescript@7.0.2"`. Snyk follows the
* yarn convention and fails to resolve the dependency.
*/
import { readFile, writeFile } from "node:fs/promises";
async function main() {
const packageJsonPath = `${process.cwd()}/package.json`;
const lockPath = `${process.cwd()}/yarn.lock`;
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
const dependencies = Object.entries<string>(
Object.assign(
{},
packageJson.dependencies,
packageJson.devDependencies,
packageJson.optionalDependencies,
),
);
let lock = await readFile(lockPath, "utf8");
const renamedKeys: string[] = [];
for (const [alias, specifier] of dependencies) {
if (!specifier.startsWith("npm:")) {
continue;
}
const aliasedPackage = specifier
.slice("npm:".length)
.replace(/@[^@]*$/, "");
if (aliasedPackage === alias) {
continue;
}
const bunKey = `"${aliasedPackage}@${specifier}"`;
const yarnKey = `"${alias}@${specifier}"`;
if (lock.includes(bunKey)) {
lock = lock.replaceAll(bunKey, yarnKey);
renamedKeys.push(`${bunKey} -> ${yarnKey}`);
}
}
if (renamedKeys.length > 0) {
await writeFile(lockPath, lock);
}
process.stdout.write(
renamedKeys.length > 0
? `Renamed alias keys in yarn.lock:\n${renamedKeys.join("\n")}\n`
: "No alias keys to rename in yarn.lock\n",
);
}
main().catch((error) => {
process.stderr.write(`Failed to rename alias keys in yarn.lock: ${error}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,237 @@
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { load } from "js-yaml";
import {
findEmptySelection,
findRunnerFailure,
} from "./check-unit-test-results";
import { prepareTestSelection } from "./prepare-test-selection";
type Step = { name: string; run?: string; "continue-on-error"?: boolean };
// js-yaml returns an untyped value, and this repository workflow defines the job's steps.
const workflow = load(
readFileSync(resolve(__dirname, "../workflows/frontend.yml"), "utf8"),
) as { jobs: { "fe-tests-unit": { steps: Step[] } } };
const steps = workflow.jobs["fe-tests-unit"].steps;
const checkScript = resolve(__dirname, "check-unit-test-results.ts");
const filter = resolve(
__dirname,
"../../frontend/test/jest-test-paths-filter.ts",
);
describe("frontend test plan handoff", () => {
let dir: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
jest.spyOn(console, "warn").mockImplementation(() => {});
// Jest reports real paths, and macOS's temporary directory is behind a symlink.
dir = realpathSync(mkdtempSync(join(tmpdir(), "frontend-test-plan-")));
mkdirSync(join(dir, "test-plan"));
writeFileSync(join(dir, "output"), "");
env = {
...process.env,
NODE_OPTIONS: "",
JEST_TEST_PATHS_FILE: "",
RUNNER_TEMP: dir,
GITHUB_OUTPUT: join(dir, "output"),
PLAN_DOWNLOADED: "success",
JEST_JUNIT_OUTPUT_DIR: dir,
JEST_JUNIT_OUTPUT_NAME: "junit.xml",
UNIT_TEST_RESULTS_FILE: join(dir, "unit-test-results.json"),
};
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
function plan(files: string[], total = 2) {
return {
fe_unit_specs_to_run: files,
stats: { unit_specs_all: total, unit_specs_to_run_usage: files.length },
};
}
function writePlan(value: unknown) {
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
}
function jestCommand(useFilter: boolean) {
return [
process.execPath,
require.resolve("jest/bin/jest"),
"--config",
JSON.stringify({
rootDir: dir,
testMatch: ["**/*.spec.cjs"],
reporters: ["default", require.resolve("jest-junit")],
...(useFilter && { filter }),
}),
];
}
function runJest(
files: Record<string, string>,
useFilter = false,
args: string[] = [],
) {
for (const [name, source] of Object.entries(files)) {
writeFileSync(join(dir, name), source);
}
const [program, ...commandArgs] = jestCommand(useFilter);
return spawnSync(
program,
[
...commandArgs,
"--runInBand",
"--watch=false",
"--json",
`--outputFile=${env.UNIT_TEST_RESULTS_FILE}`,
...(useFilter ? ["--passWithNoTests"] : []),
...args,
],
{ cwd: dir, env, encoding: "utf8" },
);
}
function readResults() {
return JSON.parse(
readFileSync(join(dir, "unit-test-results.json"), "utf8"),
);
}
function writeSelection(files: string[]) {
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
writeFileSync(env.JEST_TEST_PATHS_FILE, JSON.stringify(files));
}
it("lets ordinary assertion failures reach quarantine", () => {
expect(
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" })
.status,
).toBe(1);
expect(findRunnerFailure(env)).toBeNull();
});
it("lets suite hook failures reach quarantine", () => {
expect(
runJest({
"hook.spec.cjs":
"test('passes', () => {}); afterAll(() => { throw new Error('hook'); });",
}).status,
).toBe(1);
expect(readFileSync(join(dir, "junit.xml"), "utf8")).toContain(
"Test execution failure",
);
expect(findRunnerFailure(env)).toBeNull();
});
it("fails a filter crash even though the test step permits errors", () => {
env.JEST_TEST_PATHS_FILE = join(dir, "broken.json");
writeFileSync(env.JEST_TEST_PATHS_FILE, "[");
expect(
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
).toBe(1);
env.TEST_OUTCOME = "failure";
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("::error::");
const step = steps.find((step) =>
step.run?.includes("check-unit-test-results.ts"),
);
expect(step?.["continue-on-error"]).toBeUndefined();
});
it("fails suite startup errors even alongside ordinary assertion failures", () => {
expect(
runJest({
"crash.spec.cjs": "throw new Error('startup crash');",
"fail.spec.cjs": "test('fails', () => expect(1).toBe(2));",
}).status,
).toBe(1);
expect(readFileSync(join(dir, "junit.xml"), "utf8")).not.toContain(
"crash.spec.cjs",
);
expect(findRunnerFailure(env)).toContain("crash.spec.cjs");
});
it("fails when JUnit results are missing", () => {
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" });
rmSync(join(dir, "junit.xml"));
expect(findRunnerFailure(env)).not.toBeNull();
});
it.each([true, false])(
"loads the TypeScript filter natively with an empty selection: %s",
(empty) => {
writePlan(plan(empty ? [] : [join(dir, "pass.spec.cjs")]));
prepareTestSelection("unit", env);
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
const result = runJest(
{
"pass.spec.cjs": "test('passes', () => {});",
"fail.spec.cjs":
"test('excluded', () => { throw new Error('must not run'); });",
},
true,
);
expect(result.status === 0 ? "" : result.stderr).toBe("");
expect(result.status).toBe(0);
expect(readResults()).toMatchObject({
numTotalTestSuites: empty ? 0 : 1,
});
},
);
it("warns when a narrowed run matches none of Jest's test paths", () => {
writeSelection([join(dir, "renamed.spec.cjs")]);
expect(
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
).toBe(0);
expect(findEmptySelection(env, jestCommand(true))).toContain(
"None of the 1 selected unit specs",
);
});
it("warns through Bun when the unit test step passed", () => {
writeSelection([join(dir, "renamed.spec.cjs")]);
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
env.TEST_OUTCOME = "success";
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stderr).toContain(
"::warning::None of the 1 selected unit specs",
);
});
it("does not warn for a shard the selection leaves empty", () => {
writeSelection([join(dir, "pass.spec.cjs")]);
const result = runJest(
{ "pass.spec.cjs": "test('passes', () => {});" },
true,
["--shard=2/2"],
);
expect(result.status).toBe(0);
expect(readResults()).toMatchObject({ numTotalTestSuites: 0 });
expect(findEmptySelection(env, jestCommand(true))).toBeNull();
});
it("does not list tests for an empty selection", () => {
writeSelection([]);
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
expect(findEmptySelection(env, ["false"])).toBeNull();
});
});

View File

@@ -1,84 +0,0 @@
const githubSlackMap = require("../../release/github-slack-map.json");
async function getIssueData({ context, github, manualIssueNumber }) {
let issueNumber;
let issueData;
let prReviews = [];
const triggerType = context.eventName;
if (context.eventName === "issues") {
issueNumber = context.payload.issue.number;
issueData = context.payload.issue;
} else if (context.eventName === "workflow_dispatch") {
issueNumber = parseInt(manualIssueNumber);
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issueData = data;
} else if (context.eventName === "pull_request_review") {
const prNumber = context.payload.pull_request.number;
const { data: prData } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
// Extract issue number from branch name (e.g., "claude-fix/issue-12345-some-title")
const branchMatch = prData.head.ref.match(/claude-fix\/issue-(\d+)/);
if (!branchMatch) {
throw new Error(
`Could not extract issue number from branch name: ${prData.head.ref}`,
);
}
issueNumber = parseInt(branchMatch[1]);
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issueData = data;
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
prReviews = reviews.filter(
(review) =>
review.state === "CHANGES_REQUESTED" &&
isMetabaseMember(review.user.login),
);
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const employeeComments = comments.filter((comment) =>
isMetabaseMember(comment.user.login),
);
return {
issueNumber,
issueData,
employeeComments,
prReviews,
triggerType,
isIssueAuthorEmployee: isMetabaseMember(issueData.user.login),
};
}
function isMetabaseMember(username) {
return username in githubSlackMap;
}
module.exports = {
getIssueData,
};

View File

@@ -0,0 +1,44 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Match Jest's direct CommonJS loading contract.
const filter = require("../../frontend/test/jest-test-paths-filter");
describe("jest-test-paths-filter", () => {
const originalPathsFile = process.env.JEST_TEST_PATHS_FILE;
let tempDir: string;
let pathsFile: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "jest-test-paths-"));
pathsFile = path.join(tempDir, "paths.json");
process.env.JEST_TEST_PATHS_FILE = pathsFile;
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
if (originalPathsFile === undefined) {
delete process.env.JEST_TEST_PATHS_FILE;
} else {
process.env.JEST_TEST_PATHS_FILE = originalPathsFile;
}
});
it("should keep the listed paths and drop the rest", () => {
const one = path.resolve("frontend/one.unit.spec.ts");
const two = path.resolve("frontend/two.unit.spec.ts");
const three = path.resolve("frontend/three.unit.spec.ts");
fs.writeFileSync(pathsFile, JSON.stringify([one, two]));
expect(filter([one, three, two])).toEqual({ filtered: [one, two] });
});
it("should resolve relative paths against the working directory", () => {
fs.writeFileSync(pathsFile, JSON.stringify(["frontend/one.unit.spec.ts"]));
const one = path.resolve("frontend/one.unit.spec.ts");
const two = path.resolve("frontend/two.unit.spec.ts");
expect(filter([one, two])).toEqual({ filtered: [one] });
});
});

109
.github/scripts/linear-issues.js vendored Normal file
View File

@@ -0,0 +1,109 @@
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
query {
attachmentsForURL(url: "${githubIssueUrl}") {
nodes {
issue { identifier }
}
}
}
`,
}),
});
const data = await response.json();
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
if (!data.data?.attachmentsForURL?.nodes?.length) {
return {
success: false,
message: `No Linear issue linked to ${githubIssueUrl}`,
};
}
return {
success: true,
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
};
}
async function createLinearIssue({
teamId,
title,
description,
projectId,
labelIds,
linearApiKey,
}) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}
`,
variables: {
input: {
teamId,
title,
...(description ? { description } : {}),
...(projectId ? { projectId } : {}),
...(labelIds?.length ? { labelIds } : {}),
},
},
}),
});
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
const data = await response.json();
if (data.errors?.length) {
return {
success: false,
message: `Linear API error: ${data.errors.map((e) => e.message).join(", ")}`,
};
}
if (!data.data?.issueCreate?.success) {
return {
success: false,
message: `Failed to create Linear issue "${title}"`,
};
}
return {
success: true,
issue: data.data.issueCreate.issue,
};
}
module.exports = {
findLinearIssue,
createLinearIssue,
};

View File

@@ -24,6 +24,15 @@ const SDK_ROOT = "resources/frontend_client/app/embedding-sdk";
const SDK_RUNTIME_RE = /embedding-sdk-chunk-runtime\./;
const notRuntime = name => !SDK_RUNTIME_RE.test(name);
// One locale catalogue per chunk, named by the `webpackChunkName: "locale-[request]"`
// comment in localization.ts. A session loads exactly one and an English session
// loads none, so counting all 36 in a reachable total overstates it by 35x: 8.5 MB
// gzip against the SDK's 3.1 MB. Their size also tracks the translation process
// rather than any code change, so counting them makes every catalogue update look
// like a bundle regression.
const LOCALE_CHUNK_RE = /(^|\/)locale-/;
const notLocale = name => !LOCALE_CHUNK_RE.test(name);
const assetFiles = (root, names) => names.map(name => path.join(root, name));
const unique = files => [...new Set(files)];
@@ -116,7 +125,8 @@ function selectAppAssets(stats) {
if (!initialAssets) {
throw new Error('app: stats-main.json / "app-main" entrypoint missing');
}
return { initialAssets, reachableAssets: entrypointJsAssets(stats, "app-main", "reachableAssets") };
const reachableAssets = entrypointJsAssets(stats, "app-main", "reachableAssets");
return { initialAssets, reachableAssets: reachableAssets?.filter(notLocale) ?? null };
}
/**
@@ -136,7 +146,9 @@ function selectLegacySdkAssets(stats, { sdkAsyncChunksLoadable }) {
const initial = unique(initialNames).filter(notRuntime);
return {
initialNames: initial,
reachableNames: sdkAsyncChunksLoadable ? unique(reachableNames).filter(notRuntime) : initial,
reachableNames: sdkAsyncChunksLoadable
? unique(reachableNames).filter(notRuntime).filter(notLocale)
: initial,
};
}
@@ -161,7 +173,9 @@ function selectChunkedSdkAssets(stats, { sdkAsyncChunksLoadable }) {
const bootstrapReachable = entrypointJsAssets(stats, "embedding-sdk-bootstrap", "reachableAssets") || bootstrapInitial;
return {
initialNames: unique([...bootstrapInitial, ...chunkedInitial]).filter(notRuntime),
reachableNames: unique([...bootstrapReachable, ...chunkedReachable]).filter(notRuntime),
reachableNames: unique([...bootstrapReachable, ...chunkedReachable])
.filter(notRuntime)
.filter(notLocale),
includesAsyncChunks: chunkedReachableAssets != null && sdkAsyncChunksLoadable,
};
}

View File

@@ -6,6 +6,7 @@ const {
} = require("./measure-bundle-sizes");
const RUNTIME = "embedding-sdk-chunk-runtime.deadbeef.js";
const LOCALE = "chunks/locale-fr-json.deadbeef.js";
const stats = entrypoints => ({ entrypoints });
@@ -30,6 +31,13 @@ describe("selectAppAssets", () => {
it("throws when the app-main entrypoint is missing", () => {
expect(() => selectAppAssets(stats({}))).toThrow("app-main");
});
it("leaves the locale catalogues out of the reachable total", () => {
const s = stats({
"app-main": { assets: ["main.js"], reachableAssets: ["main.js", "locale-fr-json.abc.js"] },
});
expect(selectAppAssets(s).reachableAssets).toEqual(["main.js"]);
});
});
describe("selectChunkedSdkAssets", () => {
@@ -50,6 +58,15 @@ describe("selectChunkedSdkAssets", () => {
expect(result.includesAsyncChunks).toBe(false);
});
it("leaves the locale catalogues out of the reachable total", () => {
const withLocale = {
...base,
"embedding-sdk-chunked": { assets: ["a.js", RUNTIME], reachableAssets: ["a.js", "b.js", LOCALE, RUNTIME] },
};
const result = selectChunkedSdkAssets(stats(withLocale), { sdkAsyncChunksLoadable: true });
expect(result.reachableNames).toEqual(["boot.js", "a.js", "b.js"]);
});
it("collapses total to initial when the stats carry no reachable graph", () => {
const noGraph = { "embedding-sdk-chunked": { assets: ["a.js"] } };
const result = selectChunkedSdkAssets(stats(noGraph), { sdkAsyncChunksLoadable: true });

View File

@@ -0,0 +1,122 @@
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { TestPlan, TestPlanStats } from "./affected-tests";
type NumberStat = {
[K in keyof TestPlanStats]: TestPlanStats[K] extends number ? K : never;
}[keyof TestPlanStats];
type Suite = {
name: string;
filesKey: Exclude<keyof TestPlan, "stats">;
totalKey: NumberStat;
selectedKey: NumberStat;
pathsFile: string;
};
export const SUITES = {
unit: {
name: "unit",
filesKey: "fe_unit_specs_to_run",
totalKey: "unit_specs_all",
selectedKey: "unit_specs_to_run_usage",
pathsFile: "unit-specs.json",
},
loki: {
name: "Loki",
filesKey: "loki_stories_to_run",
totalKey: "loki_stories_all",
selectedKey: "loki_stories_to_run_usage",
pathsFile: "loki-stories.json",
},
e2e: {
name: "E2E",
filesKey: "e2e_specs_to_run",
totalKey: "e2e_specs_all",
selectedKey: "e2e_specs_to_run_usage",
pathsFile: "e2e-specs.json",
},
} satisfies Record<string, Suite>;
function isSuiteName(name: string): name is keyof typeof SUITES {
return Object.hasOwn(SUITES, name);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
function isCount(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
// null means a full run, and [] is an explicit selection of no tests.
function readSelection(planFile: string, suite: Suite): string[] | null {
const plan: unknown = JSON.parse(readFileSync(planFile, "utf8"));
const files = isRecord(plan) ? plan[suite.filesKey] : undefined;
const stats = isRecord(plan) && isRecord(plan.stats) ? plan.stats : {};
const total = stats[suite.totalKey];
if (
!isStringArray(files) ||
files.some((file) => file.length === 0) ||
!isCount(total) ||
files.length !== stats[suite.selectedKey] ||
files.length > total
) {
throw new Error(`Invalid ${suite.name} test selection`);
}
return files.length === total ? null : files;
}
export function prepareTestSelection(
suiteName: keyof typeof SUITES,
env: NodeJS.ProcessEnv = process.env,
) {
const suite: Suite = SUITES[suiteName];
const { RUNNER_TEMP, GITHUB_OUTPUT, PLAN_DOWNLOADED } = env;
if (!RUNNER_TEMP || !GITHUB_OUTPUT) {
throw new Error("RUNNER_TEMP and GITHUB_OUTPUT are required");
}
let files: string[] | null;
try {
if (PLAN_DOWNLOADED !== "success") {
throw new Error("Test plan download failed");
}
files = readSelection(join(RUNNER_TEMP, "test-plan/test-plan.json"), suite);
} catch (error) {
console.warn(
`::warning::Test plan unavailable or invalid, running the full ${suite.name} suite: ${error}`,
);
return;
}
if (files === null) {
appendFileSync(GITHUB_OUTPUT, "selection=full\n");
return;
}
const pathsFile = join(RUNNER_TEMP, suite.pathsFile);
writeFileSync(pathsFile, JSON.stringify(files));
appendFileSync(
GITHUB_OUTPUT,
`paths-file=${pathsFile}\nselection=${files.length === 0 ? "empty" : "narrowed"}\n`,
);
}
if (require.main === module) {
const suiteName = process.argv[2] ?? "";
if (!isSuiteName(suiteName)) {
throw new Error(
`Unknown test suite "${suiteName}", expected one of: ${Object.keys(SUITES).join(", ")}`,
);
}
prepareTestSelection(suiteName);
}

View File

@@ -0,0 +1,268 @@
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { load } from "js-yaml";
import { SUITES, prepareTestSelection } from "./prepare-test-selection";
type Suite = (typeof SUITES)[keyof typeof SUITES];
type Step = {
id?: string;
if?: string;
uses?: string;
run?: string;
env?: Record<string, string>;
with?: Record<string, string | boolean>;
};
const SCRIPT = resolve(__dirname, "prepare-test-selection.ts");
const SUITE_NAMES = ["unit", "loki", "e2e"] satisfies (keyof typeof SUITES)[];
function loadWorkflow(file: string) {
// js-yaml returns an untyped value, and these repository workflows define jobs with steps.
return load(
readFileSync(resolve(__dirname, "../workflows", file), "utf8"),
) as {
jobs: Record<
string,
{ if?: string; outputs?: Record<string, string>; steps?: Step[] }
>;
};
}
function loadAction() {
// js-yaml returns an untyped value, and this repository action defines outputs and steps.
return load(
readFileSync(
resolve(__dirname, "../actions/prepare-test-selection/action.yml"),
"utf8",
),
) as {
outputs: Record<string, { value: string }>;
runs: { steps: Step[] };
};
}
describe("prepareTestSelection", () => {
const { loki } = SUITES;
let dir: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
jest.spyOn(console, "warn").mockImplementation(() => {});
dir = mkdtempSync(join(tmpdir(), "test-selection-"));
mkdirSync(join(dir, "test-plan"));
writeFileSync(join(dir, "output"), "");
env = {
...process.env,
RUNNER_TEMP: dir,
GITHUB_OUTPUT: join(dir, "output"),
PLAN_DOWNLOADED: "success",
};
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
function plan(files: unknown, total = 2, suite: Suite = loki) {
return {
[suite.filesKey]: files,
stats: {
[suite.totalKey]: total,
[suite.selectedKey]: Array.isArray(files) ? files.length : 0,
},
};
}
function writePlan(value: unknown) {
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
}
const output = () => readFileSync(join(dir, "output"), "utf8");
it.each([{ files: ["one.spec.cjs"] }, { files: [] }])(
"preserves an explicit selection: $files",
({ files }) => {
writePlan(plan(files));
prepareTestSelection("loki", env);
expect(output()).toBe(
`paths-file=${join(dir, loki.pathsFile)}\nselection=${files.length > 0 ? "narrowed" : "empty"}\n`,
);
expect(
JSON.parse(readFileSync(join(dir, loki.pathsFile), "utf8")),
).toEqual(files);
},
);
it("writes the selection when invoked through Bun in CI", () => {
writePlan(plan(["one.spec.cjs"]));
const result = spawnSync("bun", [SCRIPT, "loki"], {
env,
encoding: "utf8",
});
expect(result.status === 0 ? "" : result.stderr).toBe("");
expect(result.status).toBe(0);
expect(output()).toBe(
`paths-file=${join(dir, loki.pathsFile)}\nselection=narrowed\n`,
);
});
it("writes no paths file for a full selection", () => {
writePlan(plan(["one.spec.cjs", "two.spec.cjs"]));
prepareTestSelection("loki", env);
expect(output()).toBe("selection=full\n");
});
it.each([
null,
{},
plan("[]"),
plan([null]),
{ ...plan([]), [loki.filesKey]: ["one.spec.cjs"] },
plan([""]),
plan(["one.spec.cjs"], 0),
])("runs in full for an invalid plan: %j", (value) => {
writePlan(value);
prepareTestSelection("loki", env);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("::warning::"),
);
expect(output()).toBe("");
});
it.each([
{ scenario: "the test plan is missing", setup: () => {} },
{
scenario: "the test plan is truncated",
setup: () => {
writeFileSync(
join(dir, "test-plan/test-plan.json"),
`{"${loki.filesKey}":[`,
);
},
},
{
scenario: "the download failed",
setup: () => {
writePlan(plan([])); // Even a partial download must not be trusted.
env.PLAN_DOWNLOADED = "failure";
},
},
])("runs in full when $scenario", ({ setup }) => {
setup();
prepareTestSelection("loki", env);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("::warning::"),
);
expect(output()).toBe("");
});
it.each(SUITE_NAMES)(
"reads the %s selection from its own plan keys",
(suiteName) => {
const suite = SUITES[suiteName];
writePlan(plan(["one.spec.cjs"], 2, suite));
prepareTestSelection(suiteName, env);
expect(output()).toBe(
`paths-file=${join(dir, suite.pathsFile)}\nselection=narrowed\n`,
);
expect(
JSON.parse(readFileSync(join(dir, suite.pathsFile), "utf8")),
).toEqual(["one.spec.cjs"]);
},
);
it("ignores the other suites' selections", () => {
writePlan({
...plan(["one.spec.cjs", "two.spec.cjs"]),
[SUITES.unit.filesKey]: [],
[SUITES.e2e.filesKey]: [],
});
prepareTestSelection("loki", env);
expect(console.warn).not.toHaveBeenCalled();
expect(output()).toBe("selection=full\n");
});
it("rejects an unknown suite", () => {
const result = spawnSync("bun", [SCRIPT, "toString"], {
env,
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Unknown test suite "toString"');
});
});
describe("test selection workflow steps", () => {
const ACTION = "./.github/actions/prepare-test-selection";
const CONSUMERS: Record<string, { suite: keyof typeof SUITES }> = {
"frontend.yml": { suite: "unit" },
"loki.yml": { suite: "loki" },
};
it("skips Loki visual tests only for an empty story selection", () => {
const { jobs } = loadWorkflow("loki.yml");
expect(jobs["story-selection"].outputs?.selection).toBe(
"${{ steps.loki-selection.outputs.selection }}",
);
expect(jobs["visual-test"].if).toContain(
"needs.story-selection.outputs.selection != 'empty'",
);
expect(jobs["visual-test"].if).not.toMatch(/outputs\.[\w-]+ *[!=]= *'\d+'/);
});
it.each(Object.entries(CONSUMERS))(
"selects tests through the composite action in %s",
(file, { suite, ...inputs }) => {
const steps = Object.values(loadWorkflow(file).jobs).flatMap(
({ steps = [] }) => steps.filter((step) => step.uses === ACTION),
);
expect(steps.length).toBeGreaterThan(0);
for (const step of steps) {
expect(step.with).toEqual({
suite,
"test-plan-artifact-id": expect.stringMatching(/^\$\{\{ .+ \}\}$/),
...inputs,
});
}
},
);
it("downloads the test plan before preparing the selection", () => {
const { outputs, runs } = loadAction();
const [download, prepare] = runs.steps;
expect(download).toMatchObject({
uses: expect.stringMatching(/^actions\/download-artifact@/),
"continue-on-error": "${{ inputs.require-download != 'true' }}",
with: {
"artifact-ids": "${{ inputs.test-plan-artifact-id }}",
path: "${{ runner.temp }}/test-plan",
},
});
expect(prepare).toMatchObject({
env: { PLAN_DOWNLOADED: `\${{ steps.${download.id}.outcome }}` },
run: expect.stringContaining(
'prepare-test-selection.ts "${{ inputs.suite }}"',
),
});
expect(outputs).toMatchObject({
"paths-file": {
value: `\${{ steps.${prepare.id}.outputs.paths-file }}`,
},
selection: { value: `\${{ steps.${prepare.id}.outputs.selection }}` },
});
});
});

160
.github/scripts/run-backend-test-jvms.sh vendored Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
#
# Run this runner's share of a partitioned backend test suite as one or more concurrent
# JVMs. The caller says how many runners share the suite and how many JVMs each gets.
#
# WHY MULTIPLE JVMS PER RUNNER
# ----------------------------
# Driver tests spend most of their wall time waiting on the remote warehouse, and they
# can't use the idle CPU by using more threads because some tests mutate global state.
# A second JVM gets that isolation for free.
#
# Two paths are NOT safe to share:
# * `target/junit` — hawk deletes the whole directory at `:begin-test-run`, so a JVM
# starting late would wipe an earlier one's results.
# * `logs/test-log.json` — the log4j2 File appender truncates it on open.
# So each JVM runs in its own hardlinked copy of the checkout (inodes, not disk) and
# outputs are merged back into the workspace afterwards.
#
# INPUTS (environment)
# --------------------
# CLOJURE_ALIASES alias string for `clojure -X`, e.g. dev:ci:ee:ee-dev:drivers:drivers-dev:test
# TEST_RUNNER_INDEX index of this runner among those sharing the suite
# TEST_RUNNER_COUNT how many runners share the suite
# TEST_JVMS_PER_RUNNER concurrent JVMs per runner (default 1)
# TEST_HEAP_PER_JVM -Xmx per JVM, applied only when running more than one
#
# Exits non-zero if any JVM fails.
set -uo pipefail
: "${CLOJURE_ALIASES:?CLOJURE_ALIASES is required}"
: "${TEST_RUNNER_INDEX:?TEST_RUNNER_INDEX is required}"
: "${TEST_RUNNER_COUNT:?TEST_RUNNER_COUNT is required}"
TEST_ARGS=("$@")
TEST_JVMS_PER_RUNNER="${TEST_JVMS_PER_RUNNER:-1}"
TEST_HEAP_PER_JVM="${TEST_HEAP_PER_JVM:-4g}"
die() {
echo "::error::run-backend-test-jvms.sh: $1"
exit 1
}
[[ "$TEST_RUNNER_INDEX" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_INDEX must be a non-negative integer, got '$TEST_RUNNER_INDEX'"
[[ "$TEST_RUNNER_COUNT" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_COUNT must be a positive integer, got '$TEST_RUNNER_COUNT'"
[[ "$TEST_JVMS_PER_RUNNER" =~ ^[0-9]+$ ]] || die "TEST_JVMS_PER_RUNNER must be a positive integer, got '$TEST_JVMS_PER_RUNNER'"
[ "$TEST_RUNNER_COUNT" -ge 1 ] || die "TEST_RUNNER_COUNT must be at least 1"
[ "$TEST_JVMS_PER_RUNNER" -ge 1 ] || die "TEST_JVMS_PER_RUNNER must be at least 1"
[ "$TEST_RUNNER_INDEX" -lt "$TEST_RUNNER_COUNT" ] ||
die "runner index $TEST_RUNNER_INDEX is out of range for $TEST_RUNNER_COUNT runner(s) — a matrix entry was added without raising runner-count"
# Partition indexes are split across runners rather than handed out in contiguous
# blocks. E.g. with 2 runners and 2 JVMs the first runner gets partition 0 and 2, and
# the second runner gets partitions 1 and 3. Hawk partitions in namespace order, so
# neighbouring partitions have similar runtimes — contiguous blocks would group the slow
# ones together onto one runner.
PARTITION_TOTAL=$((TEST_RUNNER_COUNT * TEST_JVMS_PER_RUNNER))
INDEXES=()
for ((k = 0; k < TEST_JVMS_PER_RUNNER; k++)); do
INDEXES+=($((TEST_RUNNER_INDEX + k * TEST_RUNNER_COUNT)))
done
# One JVM: run directly in the workspace with no heap override, exactly as a
# partitioned run behaved before this script existed.
if [ "$TEST_JVMS_PER_RUNNER" -eq 1 ]; then
echo "Running partition ${INDEXES[0]} of $PARTITION_TOTAL"
exec clojure -X:"$CLOJURE_ALIASES" "${TEST_ARGS[@]}" \
:partition/total "$PARTITION_TOTAL" \
:partition/index "${INDEXES[0]}"
fi
WORKSPACE="$PWD"
# Sibling of the checkout: same filesystem (so `cp -al` can hardlink) but outside the
# repo, so one JVM's directory never gets copied into another's.
JVM_DIRS_ROOT="$(dirname "$WORKSPACE")/backend-test-jvms"
STATUS_DIR="$JVM_DIRS_ROOT/status"
# Merge every JVM's results into the workspace the rest of the job reads from.
#
# Each partition's junit XML goes into its own subdirectory: `mb_hawk_var_less_errors.xml`
# has a fixed name, so a flat copy would silently overwrite it. Nesting is safe because
# both consumers (dorny/test-reporter and ci-conductor's backend adapter) walk
# `target/junit` recursively and match on filename suffix.
#
# Runs from an EXIT trap so a cancelled or timed-out job still publishes whatever
# finished.
# shellcheck disable=SC2329 # invoked by the EXIT trap below, which shellcheck can't see.
merge_outputs() {
mkdir -p "$WORKSPACE/target/junit" "$WORKSPACE/logs"
local idx jvm_dir
for idx in "${INDEXES[@]}"; do
jvm_dir="$JVM_DIRS_ROOT/partition-$idx"
if [ -d "$jvm_dir/target/junit" ]; then
mkdir -p "$WORKSPACE/target/junit/p$idx"
cp -a "$jvm_dir/target/junit/." "$WORKSPACE/target/junit/p$idx/" || true
fi
# `logs/test-log*.json` is what the action uploads on failure.
if [ -f "$jvm_dir/logs/test-log.json" ]; then
cp -a "$jvm_dir/logs/test-log.json" "$WORKSPACE/logs/test-log-p$idx.json" || true
fi
done
}
trap merge_outputs EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
# Hardlinked copy of the checkout. Skips the paths a run writes to (`target`, `logs`, `.cpcache`)
# and the two most expensive trees nothing on the test classpath reads (`.git`, `node_modules`).
prepare_jvm_dir() {
local jvm_dir="$1" entry
rm -rf "$jvm_dir"
mkdir -p "$jvm_dir"
for entry in "$WORKSPACE"/* "$WORKSPACE"/.[!.]*; do
[ -e "$entry" ] || continue
case "$(basename "$entry")" in
.git | node_modules | target | logs | .cpcache) continue ;;
esac
# Fall back to a real copy if the filesystem refuses the hardlink.
cp -al "$entry" "$jvm_dir/" 2>/dev/null || cp -a "$entry" "$jvm_dir/"
done
}
# Prefix output with the partition index so interleaved JVM logs stay readable.
run_partition() {
local idx="$1" jvm_dir="$2" rc line
(
cd "$jvm_dir" || exit 1
# `-J` options land after the aliases' `:jvm-opts`, overriding the `:ci` alias's
# `-Xms12g -Xmx12g`. Xms stays small so JVMs sharing a runner grow into memory on
# demand rather than each committing its ceiling up front.
exec clojure -J-Xms512m -J-Xmx"$TEST_HEAP_PER_JVM" -X:"$CLOJURE_ALIASES" \
"${TEST_ARGS[@]}" \
:partition/total "$PARTITION_TOTAL" \
:partition/index "$idx"
) 2>&1 | while IFS= read -r line || [ -n "$line" ]; do printf '[p%s] %s\n' "$idx" "$line"; done
rc="${PIPESTATUS[0]}"
echo "$rc" > "$STATUS_DIR/p$idx"
}
rm -rf "$JVM_DIRS_ROOT"
mkdir -p "$STATUS_DIR"
echo "Runner $TEST_RUNNER_INDEX of $TEST_RUNNER_COUNT: running $TEST_JVMS_PER_RUNNER concurrent JVMs for partition(s) ${INDEXES[*]} of $PARTITION_TOTAL (-Xmx$TEST_HEAP_PER_JVM each)"
for idx in "${INDEXES[@]}"; do
prepare_jvm_dir "$JVM_DIRS_ROOT/partition-$idx"
done
for idx in "${INDEXES[@]}"; do
run_partition "$idx" "$JVM_DIRS_ROOT/partition-$idx" &
done
wait
overall=0
for idx in "${INDEXES[@]}"; do
rc="$(cat "$STATUS_DIR/p$idx" 2>/dev/null || echo 1)"
echo "partition $idx exited $rc"
[ "$rc" -eq 0 ] || overall=1
done
exit "$overall"

Some files were not shown because too many files have changed in this diff Show More